mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4efdb07dae | ||
|
|
7feda3dad3 | ||
|
|
f08ad574d3 | ||
|
|
1bfc25a860 | ||
|
|
063dd8ea75 | ||
|
|
6af9317556 | ||
|
|
410562409d | ||
|
|
9498adc7c4 | ||
|
|
ebf370595d | ||
|
|
bcdbf13d02 | ||
|
|
3dc621618f | ||
|
|
fd0b60fefc | ||
|
|
95a9857984 | ||
|
|
f9c89308fc | ||
|
|
65d08e9d94 | ||
|
|
45717780fa | ||
|
|
9da7c30880 | ||
|
|
98e791f258 | ||
|
|
b9e0928aef | ||
|
|
652a522e50 | ||
|
|
ce1a9ce133 | ||
|
|
ae45a4f67c | ||
|
|
697eed23d4 | ||
|
|
100595f8aa | ||
|
|
dd03a55028 | ||
|
|
a72218f99f | ||
|
|
eaa76032d5 | ||
|
|
ed01ab8c8d | ||
|
|
a65f663d2e | ||
|
|
c6382f2473 | ||
|
|
2922a2b154 | ||
|
|
403dec8e98 | ||
|
|
2c7cf6118c | ||
|
|
2bdd860b54 | ||
|
|
81f1ffbb4f | ||
|
|
93fc7b9e77 | ||
|
|
9fc5b875d1 | ||
|
|
08279e6b99 | ||
|
|
a7c31e89b4 | ||
|
|
04014c658a | ||
|
|
c1238d3e7e | ||
|
|
687e80a55a | ||
|
|
b90fd01af2 | ||
|
|
bbe7df7d33 | ||
|
|
9685b9b78f | ||
|
|
aa2d127de4 | ||
|
|
87f6238338 | ||
|
|
452bcc38cf | ||
|
|
b35a4c8113 | ||
|
|
f001e3b0ca | ||
|
|
b6dba93ae5 | ||
|
|
3000116d18 | ||
|
|
9db21d37ef | ||
|
|
95480363b7 | ||
|
|
4419b76412 | ||
|
|
99bbc2054a | ||
|
|
d5d8fddc94 | ||
|
|
cadb3e2ae6 | ||
|
|
7dc904c1b2 | ||
|
|
d9725fbb6a | ||
|
|
23f04264f9 | ||
|
|
8b59eb87e0 | ||
|
|
2e68e227b7 | ||
|
|
fc98614437 | ||
|
|
b1c5aba6fd | ||
|
|
6240c59ca3 | ||
|
|
9f3c7fd086 | ||
|
|
657c8dd26b | ||
|
|
4ef296e9d0 | ||
|
|
d5d06ca0e5 | ||
|
|
213ee4ff7e | ||
|
|
215ab76e5f | ||
|
|
0812ee0701 | ||
|
|
0b140110d8 | ||
|
|
2623f9e0f4 | ||
|
|
d454c41500 | ||
|
|
e3fb816d12 | ||
|
|
3486f27357 | ||
|
|
928776a71c | ||
|
|
2c2a4b6ae4 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "139,590",
|
||||
"message": "185,599",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 139590,
|
||||
"last_updated": "2026-07-01T07:33:10Z",
|
||||
"total_clones": 185599,
|
||||
"last_updated": "2026-08-10T07:26:44Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -97,6 +97,46 @@
|
||||
"2026-06-27": 1338,
|
||||
"2026-06-28": 1028,
|
||||
"2026-06-29": 765,
|
||||
"2026-06-30": 951
|
||||
"2026-06-30": 951,
|
||||
"2026-07-01": 1134,
|
||||
"2026-07-02": 593,
|
||||
"2026-07-03": 537,
|
||||
"2026-07-04": 411,
|
||||
"2026-07-05": 485,
|
||||
"2026-07-06": 555,
|
||||
"2026-07-07": 905,
|
||||
"2026-07-08": 1171,
|
||||
"2026-07-09": 1857,
|
||||
"2026-07-10": 1181,
|
||||
"2026-07-11": 2185,
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362,
|
||||
"2026-07-16": 2497,
|
||||
"2026-07-17": 1773,
|
||||
"2026-07-18": 1542,
|
||||
"2026-07-19": 1445,
|
||||
"2026-07-20": 1481,
|
||||
"2026-07-21": 1528,
|
||||
"2026-07-22": 1529,
|
||||
"2026-07-23": 1209,
|
||||
"2026-07-24": 1118,
|
||||
"2026-07-25": 928,
|
||||
"2026-07-26": 740,
|
||||
"2026-07-27": 799,
|
||||
"2026-07-28": 665,
|
||||
"2026-07-29": 745,
|
||||
"2026-07-30": 591,
|
||||
"2026-07-31": 783,
|
||||
"2026-08-01": 567,
|
||||
"2026-08-02": 1248,
|
||||
"2026-08-03": 724,
|
||||
"2026-08-04": 708,
|
||||
"2026-08-05": 647,
|
||||
"2026-08-06": 604,
|
||||
"2026-08-07": 624,
|
||||
"2026-08-08": 706,
|
||||
"2026-08-09": 1076
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ jobs:
|
||||
- name: Ruff check
|
||||
run: uv run ruff check src/ tests/
|
||||
|
||||
- name: Ruff format check
|
||||
run: uv run ruff format --check src/ tests/
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.PHONY: setup build test lint format
|
||||
|
||||
# Mirrors .github/workflows/ci.yml so `make test` matches CI locally.
|
||||
|
||||
setup:
|
||||
uv sync --extra dev --extra framework-comparison --extra server
|
||||
|
||||
build:
|
||||
uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
test: build
|
||||
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub"
|
||||
|
||||
lint:
|
||||
uv run ruff check src/ tests/
|
||||
uv run ruff format --check src/ tests/
|
||||
|
||||
format:
|
||||
uv run ruff format src/ tests/
|
||||
@@ -4,7 +4,8 @@
|
||||
<p><i>Personal AI, On Personal Devices.</i></p>
|
||||
|
||||
<p>
|
||||
<a href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
|
||||
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
|
||||
@@ -23,7 +24,7 @@
|
||||
|
||||
> **[Documentation](https://open-jarvis.github.io/OpenJarvis/)**
|
||||
>
|
||||
> **[Project Site](https://scalingintelligence.stanford.edu/blogs/openjarvis/)**
|
||||
> **[Project Site](https://openjarvis.stanford.edu/)**
|
||||
>
|
||||
> **[Paper](https://arxiv.org/abs/2605.17172)**
|
||||
>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Full system access: unrestricted shell and filesystem
|
||||
# Copy to ~/.openjarvis/config.toml
|
||||
#
|
||||
# WARNING: shell_exec runs arbitrary commands as your user. No command
|
||||
# allowlist, no denylist, no working-directory restriction. file_read and
|
||||
# file_write aren't restricted to any directory either. Only enable what you
|
||||
# actually want the agent to have. tools.enabled is the whole permission grant;
|
||||
# there's no second allowlist to configure.
|
||||
#
|
||||
# On macOS, this config alone does not reach TCC-protected data (Messages,
|
||||
# Mail, Photos, Safari). That requires Full Disk Access granted to the process
|
||||
# hosting the backend. See docs/user-guide/system-access.md.
|
||||
#
|
||||
# Usage:
|
||||
# jarvis ask "What's using the most disk space in my home directory?"
|
||||
# jarvis chat # prompts before each shell_exec call
|
||||
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3.5:9b"
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
max_turns = 10
|
||||
|
||||
[tools]
|
||||
enabled = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"apply_patch",
|
||||
"code_interpreter",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"think",
|
||||
"calculator",
|
||||
]
|
||||
@@ -604,7 +604,7 @@ enforce_tool_confirmation = true
|
||||
| `scan_output` | bool | `true` | Whether to scan model output. |
|
||||
| `secret_scanner` | bool | `true` | Enable secret detection (API keys, tokens, passwords). |
|
||||
| `pii_scanner` | bool | `true` | Enable PII detection (emails, SSNs, credit cards). |
|
||||
| `enforce_tool_confirmation` | bool | `true` | Require confirmation before executing tools. |
|
||||
| `enforce_tool_confirmation` | bool | `true` | Accepted but **not currently enforced**. Whether you get prompts depends on the entry point. See [System Access](../user-guide/system-access.md#confirmation-behaviour). |
|
||||
|
||||
!!! tip "Choosing a security mode"
|
||||
Use `"warn"` during development to see what would be flagged without disrupting output.
|
||||
|
||||
@@ -135,6 +135,19 @@ cd OpenJarvis
|
||||
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
|
||||
You get a ChatGPT-like interface with streaming responses, tool use, energy monitoring, and a telemetry dashboard — all running locally on your hardware.
|
||||
|
||||
Web search is available through the built-in DuckDuckGo fallback. To use
|
||||
Tavily, add `TAVILY_API_KEY` under **Settings → Tools → Web Search** after the
|
||||
app starts, or export it before starting quickstart:
|
||||
|
||||
```bash
|
||||
export TAVILY_API_KEY="tvly-..."
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
The script does not automatically source `.env` files. Run `source .env`
|
||||
first if that is where you keep the key. Stop any existing OpenJarvis server
|
||||
before restarting so it inherits the updated environment.
|
||||
|
||||
To stop all services, press ++ctrl+c++ in the terminal.
|
||||
|
||||
!!! tip "Environment variable"
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ OpenJarvis is built around five composable layers. Each has a clean interface an
|
||||
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. Developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
|
||||
Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/) for the full research motivation, architecture details, and experimental results.
|
||||
Read the [blog post](https://openjarvis.stanford.edu/) for the full research motivation, architecture details, and experimental results.
|
||||
|
||||
## Citation
|
||||
|
||||
|
||||
+1
-1
@@ -55,5 +55,5 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
|
||||
<div id="leaderboard-pagination" class="lb-pagination"></div>
|
||||
|
||||
<p style="font-size:12px;opacity:0.6;margin-top:12px">
|
||||
*Dollar savings estimated vs. Claude Opus 4.6 API pricing ($5/1M input, $25/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
*Dollar savings estimated vs. Claude Fable 5 API pricing ($10/1M input, $50/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
</p>
|
||||
|
||||
@@ -392,7 +392,7 @@ enforce_tool_confirmation = true
|
||||
| `secret_scanner` | `bool` | `true` | Run `SecretScanner` on all text |
|
||||
| `pii_scanner` | `bool` | `true` | Run `PIIScanner` on all text |
|
||||
| `audit_log_path` | `str` | `~/.openjarvis/audit.db` | Path to the SQLite audit log |
|
||||
| `enforce_tool_confirmation` | `bool` | `true` | Require explicit confirmation before tool execution |
|
||||
| `enforce_tool_confirmation` | `bool` | `true` | Accepted by the loader but **not currently enforced**. See [System Access](system-access.md#confirmation-behaviour) for when prompts actually happen |
|
||||
|
||||
!!! tip "Start with warn, tighten later"
|
||||
`mode = "warn"` is a good starting point. It lets you observe what patterns are being triggered without disrupting normal usage. Switch to `"redact"` once you are satisfied that the scanner isn't producing too many false positives for your workload.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# System Access
|
||||
|
||||
How to give an agent access to the machine it runs on, and where the real
|
||||
limits are.
|
||||
|
||||
!!! warning
|
||||
`shell_exec` runs arbitrary commands as your user. There is no command
|
||||
allowlist, no denylist, and no sandbox unless you turn one on. An agent
|
||||
holding this tool can do anything you can do from a terminal.
|
||||
|
||||
---
|
||||
|
||||
## Start here: you probably have no tools enabled
|
||||
|
||||
If the agent tells you it can't run commands or read files, that's usually not
|
||||
a permissions problem. It means no tools were enabled in the first place.
|
||||
|
||||
Tools come from `tools.enabled`, falling back to `agent.tools`. Both default to
|
||||
empty, and an empty value builds the agent with **zero tools**. Nothing is
|
||||
enabled by default.
|
||||
|
||||
First check whether you have a config file at all:
|
||||
|
||||
```bash
|
||||
cat ~/.openjarvis/config.toml
|
||||
```
|
||||
|
||||
If it isn't there, that's your answer. Create it:
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3.5:9b"
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
|
||||
[tools]
|
||||
enabled = ["shell_exec", "file_read", "file_write", "think"]
|
||||
```
|
||||
|
||||
There's a fuller version at
|
||||
`configs/openjarvis/examples/full-system-access.toml`.
|
||||
|
||||
Then confirm the list actually resolved:
|
||||
|
||||
```bash
|
||||
python -c "from openjarvis.core.config import load_config; print(load_config().tools.enabled)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What the tools reach
|
||||
|
||||
| Tool | Scope |
|
||||
|------|-------|
|
||||
| `shell_exec` | Any command, as your user. 30s default timeout, 300s max, output capped at 100 KB per stream. |
|
||||
| `file_read` | Any readable path. 1 MB cap. |
|
||||
| `file_write` | Any writable path. 10 MB cap, can create parent directories. |
|
||||
| `apply_patch` | Applies unified diffs to any path. |
|
||||
| `code_interpreter` | Python in a subprocess, behind a coarse pattern blocklist. |
|
||||
|
||||
`file_read` and `file_write` take an `allowed_dirs` argument that limits them to
|
||||
a set of directories, but no config key populates it. When it's empty every path
|
||||
is allowed. If you want a filesystem jail today, use the container sandbox
|
||||
instead of relying on these tools to enforce one.
|
||||
|
||||
### Sensitive filenames
|
||||
|
||||
`file_read` and `file_write` refuse names matching a short glob list: `.env`,
|
||||
`*.pem`, `id_rsa`, `credentials.*` and a dozen or so others. It matches on the
|
||||
filename only, not the path or the contents, and only those two tools consult
|
||||
it. `shell_exec`, `apply_patch` and `code_interpreter` skip it entirely, so
|
||||
`cat ~/.ssh/id_rsa` through `shell_exec` works fine. Treat it as protection
|
||||
against fat fingers, not as a security boundary.
|
||||
|
||||
---
|
||||
|
||||
## Confirmation behaviour
|
||||
|
||||
`shell_exec`, `git_commit` and `agent_kill` are marked `requires_confirmation`.
|
||||
What that translates to depends entirely on how you launched the agent:
|
||||
|
||||
| Entry point | Behaviour |
|
||||
|-------------|-----------|
|
||||
| `jarvis chat` | Prompts before each call. |
|
||||
| `jarvis ask` | Auto-approves. |
|
||||
| `jarvis agent ask` | Auto-approves. Pass `--no-yes` if you want prompts. |
|
||||
| HTTP server, desktop app | Auto-approves. Tools you added to an agent's toolkit count as pre-approved. |
|
||||
| Embedded via `SystemBuilder` | No callback is wired, so these tools fail closed. |
|
||||
|
||||
That last row catches people out. If `shell_exec` returns "requires
|
||||
confirmation but no confirmation callback is available", you're constructing the
|
||||
agent yourself and need to pass a `confirm_callback`.
|
||||
|
||||
!!! note "`enforce_tool_confirmation` doesn't do anything"
|
||||
The config loader accepts `security.enforce_tool_confirmation`, but nothing
|
||||
on the tool execution path reads it. Setting it won't change confirmation
|
||||
behaviour anywhere. Use the table above instead.
|
||||
|
||||
---
|
||||
|
||||
## macOS: Full Disk Access
|
||||
|
||||
On macOS the operating system is the real boundary, not the config. Shell
|
||||
access and ordinary file access start working as soon as you enable the tools.
|
||||
TCC-protected data does not: Messages, Mail, Photos, Safari history, Contacts
|
||||
and Calendar all stay locked, and no config key will change that.
|
||||
|
||||
Grant Full Disk Access to whichever process hosts the backend. Child processes
|
||||
inherit it:
|
||||
|
||||
| How you run OpenJarvis | Grant access to |
|
||||
|------------------------|-----------------|
|
||||
| CLI (`jarvis ask`, `jarvis chat`) | Your terminal (Terminal, iTerm, Warp) |
|
||||
| Desktop app | `OpenJarvis.app`, which spawns `jarvis serve` beneath it |
|
||||
| launchd (`deploy/launchd/com.openjarvis.plist`) | The `jarvis` binary, as its own entry |
|
||||
|
||||
System Settings, then Privacy & Security, then Full Disk Access, then **+**.
|
||||
|
||||
A launchd daemon gets its own TCC context, so granting access to Terminal does
|
||||
nothing for it. Add `/usr/local/bin/jarvis` separately.
|
||||
|
||||
To check whether the grant took:
|
||||
|
||||
```bash
|
||||
head -c 16 ~/Library/Messages/chat.db >/dev/null 2>&1 \
|
||||
&& echo "granted" || echo "denied"
|
||||
```
|
||||
|
||||
Restart the host process after you change the setting.
|
||||
|
||||
### Driving Mac apps
|
||||
|
||||
AppleScript works through `shell_exec`:
|
||||
|
||||
```
|
||||
osascript -e 'tell application "Music" to play'
|
||||
```
|
||||
|
||||
macOS asks for Automation permission once per target app, the first time you
|
||||
touch it.
|
||||
|
||||
---
|
||||
|
||||
## What you can't do
|
||||
|
||||
There's no computer use. OpenJarvis can't see your screen, move the pointer or
|
||||
send keystrokes. No tool for it is registered and no input automation library
|
||||
appears anywhere in the codebase, so granting Accessibility or Screen Recording
|
||||
buys you nothing on its own.
|
||||
|
||||
The `click` and `type` actions you'll find are Playwright, scoped to a browser
|
||||
page rather than the desktop.
|
||||
|
||||
Some of this is reachable through `shell_exec` if you bring the tooling
|
||||
yourself. `screencapture` will take screenshots once you've granted Screen
|
||||
Recording, and something like `cliclick` will move the pointer. That gets you
|
||||
scripted actions. It doesn't get you an agent that looks at the screen and
|
||||
works out where to click.
|
||||
|
||||
---
|
||||
|
||||
## Narrowing access
|
||||
|
||||
Access widens and narrows through `tools.enabled`. Drop entries to take
|
||||
capabilities away. That list is the whole grant.
|
||||
|
||||
Two stronger isolation options exist. Both are off by default:
|
||||
|
||||
```toml
|
||||
[sandbox]
|
||||
enabled = true # run tools inside a container
|
||||
runtime = "docker"
|
||||
|
||||
[security.capabilities]
|
||||
enabled = true # RBAC over declared tool capabilities
|
||||
policy_path = "~/.openjarvis/policy.yaml"
|
||||
```
|
||||
|
||||
!!! note "Capabilities are open by default even once enabled"
|
||||
`CapabilityPolicy` is built with `default_deny=False` and no config key
|
||||
exposes that flag, so an agent with no explicit policy entry gets every
|
||||
capability. Write entries for every agent you mean to restrict.
|
||||
|
||||
For anything untrusted, reach for `docker_shell_exec` and
|
||||
`code_interpreter_docker` rather than the host-side versions.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [Security](security.md) for scanners, the audit log and guardrails
|
||||
- [Tools](tools.md) for the full registry
|
||||
- [Code Assistant](code-assistant.md) for a narrower shell-enabled setup
|
||||
- [External MCP Servers](mcp-external-servers.md) for capabilities OpenJarvis doesn't ship
|
||||
@@ -89,7 +89,7 @@ export default function App() {
|
||||
setSavings(data);
|
||||
if (optInEnabled && optInDisplayName && data) {
|
||||
const claudeEntry = data.per_provider.find(
|
||||
(p) => p.provider === 'claude-opus-4.6',
|
||||
(p) => p.provider === 'claude-fable-5',
|
||||
);
|
||||
const dollarSavings = claudeEntry ? claudeEntry.total_cost : 0;
|
||||
const energySaved = data.per_provider.reduce(
|
||||
|
||||
@@ -22,6 +22,8 @@ export function ChatArea() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -34,15 +36,34 @@ export function ChatArea() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
const distance = scrollHeight - scrollTop - clientHeight;
|
||||
const scrolledUp = scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = scrollTop;
|
||||
if (scrolledUp && distance >= 1) {
|
||||
// Any upward scroll away from the bottom stops autoscroll immediately,
|
||||
// so streaming content never fights the user (no jitter). Sub-1px
|
||||
// upward movement (elastic bounce settling at the bottom) is ignored.
|
||||
shouldAutoScroll.current = false;
|
||||
} else if (!scrolledUp) {
|
||||
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
|
||||
// at fractional zoom levels the at-bottom residual can reach 1px,
|
||||
// which would otherwise leave autoscroll permanently disengaged.
|
||||
shouldAutoScroll.current = distance < 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
|
||||
@@ -466,7 +466,10 @@ export function InputArea() {
|
||||
}
|
||||
const totalMs = Date.now() - startTime;
|
||||
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/', 'MiniMax-', 'chatgpt-'];
|
||||
const engineLabel = _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
|
||||
const selectedOwner = useAppStore.getState().models.find((m) => m.id === selectedModel)?.owned_by;
|
||||
const engineLabel = selectedOwner === 'litellm'
|
||||
? 'litellm'
|
||||
: _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
|
||||
const telemetry: MessageTelemetry = {
|
||||
engine: engineLabel,
|
||||
model_id: selectedModel,
|
||||
|
||||
@@ -29,8 +29,8 @@ interface TelemetryStats {
|
||||
}
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00, primary: true },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00, primary: false },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00, primary: true },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00, primary: false },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00, primary: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -143,18 +143,17 @@ export function CommandPalette() {
|
||||
}
|
||||
}, [pullSuccess]);
|
||||
|
||||
const handleSelect = async (modelId: string) => {
|
||||
const handleSelect = async (modelId: string, owner?: string) => {
|
||||
const previousModel = selectedModel;
|
||||
setSelectedModel(modelId);
|
||||
setCommandPaletteOpen(false);
|
||||
|
||||
if (modelId !== previousModel) {
|
||||
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
createConversation(modelId);
|
||||
const { setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
setModelLoading(true);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
|
||||
try {
|
||||
await preloadModel(modelId);
|
||||
await preloadModel(modelId, owner);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `${modelId} loaded` });
|
||||
} catch (e: any) {
|
||||
addLogEntry({ timestamp: Date.now(), level: 'error', category: 'model', message: `Failed to load ${modelId}: ${e.message}` });
|
||||
@@ -256,7 +255,8 @@ export function CommandPalette() {
|
||||
setSelectedIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && tab === 'installed' && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
handleSelect((filtered[selectedIdx] as any).id);
|
||||
const model = filtered[selectedIdx] as (typeof models)[number];
|
||||
handleSelect(model.id, model.owned_by);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,11 +366,15 @@ export function CommandPalette() {
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelect(model.id)}
|
||||
onClick={() => handleSelect(model.id, model.owned_by)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
|
||||
style={{ background: 'none', border: 'none', padding: 0 }}
|
||||
>
|
||||
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
{model.owned_by === 'litellm' ? (
|
||||
<Cloud size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
) : (
|
||||
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate" style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text)', fontWeight: isActive ? 500 : 400 }}>
|
||||
{model.id}
|
||||
@@ -382,17 +386,19 @@ export function CommandPalette() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(model.id)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
|
||||
title="Delete model"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
|
||||
>
|
||||
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
{model.owned_by !== 'litellm' && (
|
||||
<button
|
||||
onClick={() => handleDelete(model.id)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
|
||||
title="Delete model"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
|
||||
>
|
||||
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00 },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00 },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00 },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
|
||||
];
|
||||
|
||||
|
||||
@@ -222,8 +222,8 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
};
|
||||
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
'gpt-5.3': colors.green,
|
||||
'claude-opus-4.6': colors.yellow,
|
||||
'gpt-5.6-sol': colors.green,
|
||||
'claude-fable-5': colors.yellow,
|
||||
'gemini-3.1-pro': colors.accent,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// authHeaders) that source the key and build the header.
|
||||
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
// Minimal in-memory localStorage stub so the helpers can run under node
|
||||
// (no jsdom dependency).
|
||||
@@ -28,6 +29,8 @@ class MemoryStorage {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
fetchMock.mockReset();
|
||||
globalThis.fetch = fetchMock;
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
@@ -86,3 +89,50 @@ describe('authHeaders', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool credentials', () => {
|
||||
it('reads credential status from the local server', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ TAVILY_API_KEY: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const { fetchToolCredentialStatus } = await freshApi();
|
||||
|
||||
await expect(fetchToolCredentialStatus('web_search')).resolves.toEqual({
|
||||
TAVILY_API_KEY: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/status',
|
||||
{ headers: {} },
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { saveToolCredentials } = await freshApi();
|
||||
|
||||
await saveToolCredentials('web_search', {
|
||||
TAVILY_API_KEY: 'tvly-test',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/tools/web_search/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ TAVILY_API_KEY: 'tvly-test' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { deleteToolCredential } = await freshApi();
|
||||
|
||||
await deleteToolCredential('web_search', 'TAVILY_API_KEY');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/TAVILY_API_KEY',
|
||||
{ method: 'DELETE', headers: {} },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+21
-2
@@ -218,9 +218,9 @@ export async function deleteModel(modelName: string): Promise<void> {
|
||||
|
||||
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/'];
|
||||
|
||||
export async function preloadModel(modelName: string): Promise<void> {
|
||||
export async function preloadModel(modelName: string, owner?: string): Promise<void> {
|
||||
// Cloud models don't need Ollama preloading
|
||||
if (_CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
|
||||
if (owner === 'litellm' || _CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
|
||||
return;
|
||||
}
|
||||
// Trigger Ollama to load the model into memory (empty prompt, no generation).
|
||||
@@ -885,6 +885,25 @@ export async function saveToolCredentials(
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchToolCredentialStatus(
|
||||
toolName: string,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const res = await apiFetch(`/v1/tools/${toolName}/credentials/status`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteToolCredential(
|
||||
toolName: string,
|
||||
keyName: string,
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`/v1/tools/${encodeURIComponent(toolName)}/credentials/${encodeURIComponent(keyName)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export interface AgentTraceDetail {
|
||||
id: string;
|
||||
agent: string;
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
fetchAgentTrace,
|
||||
fetchManagedAgent,
|
||||
fetchAvailableTools,
|
||||
saveToolCredentials,
|
||||
fetchModels,
|
||||
updateManagedAgent,
|
||||
fetchRecommendedModel,
|
||||
@@ -575,7 +574,7 @@ function ToolsPicker({
|
||||
</div>
|
||||
{/* Live description strip */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1.5"
|
||||
className="flex items-start gap-2 px-2.5 py-1.5"
|
||||
style={{
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
@@ -609,10 +608,11 @@ function ToolsPicker({
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="truncate"
|
||||
className="min-w-0 whitespace-normal break-words"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{hovered ? `— ${hint}` : hint}
|
||||
@@ -3740,8 +3740,8 @@ export function AgentsPage() {
|
||||
const paramsB = paramMatch ? parseFloat(paramMatch[1]) : 9;
|
||||
const flops = 2 * paramsB * 1e9 * (inTok + outTok);
|
||||
const providers = [
|
||||
{ label: 'GPT-5.3', inPer1M: 2.0, outPer1M: 10.0 },
|
||||
{ label: 'Claude Opus 4.6', inPer1M: 5.0, outPer1M: 25.0 },
|
||||
{ label: 'GPT-5.6 Sol', inPer1M: 5.0, outPer1M: 30.0 },
|
||||
{ label: 'Claude Fable 5', inPer1M: 10.0, outPer1M: 50.0 },
|
||||
{ label: 'Gemini 3.1 Pro', inPer1M: 2.0, outPer1M: 12.0 },
|
||||
];
|
||||
const energyWh = (inTok + outTok) / 1000 * 0.4;
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
setInferenceSource,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
fetchToolCredentialStatus,
|
||||
saveToolCredentials,
|
||||
deleteToolCredential,
|
||||
isTauri,
|
||||
type InferenceSource,
|
||||
} from '../lib/api';
|
||||
@@ -56,25 +59,37 @@ function OllamaModelList() {
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
|
||||
function ApiKeyInput({
|
||||
keyName,
|
||||
placeholder,
|
||||
toolName,
|
||||
}: {
|
||||
keyName: string;
|
||||
placeholder: string;
|
||||
toolName?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const desktopKeyStorage = isTauri();
|
||||
const serverToolStorage = !desktopKeyStorage && !!toolName;
|
||||
const canManage = desktopKeyStorage || serverToolStorage;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
if (!canManage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
const status = desktopKeyStorage
|
||||
? await getCloudKeyStatus()
|
||||
: await fetchToolCredentialStatus(toolName!);
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
}, [canManage, desktopKeyStorage, keyName, toolName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -87,7 +102,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
||||
if (!next) return;
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, next);
|
||||
if (desktopKeyStorage) {
|
||||
await saveCloudKey(keyName, next);
|
||||
} else if (toolName) {
|
||||
await saveToolCredentials(toolName, { [keyName]: next });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setValue('');
|
||||
setHasKey(true);
|
||||
setSaved(true);
|
||||
@@ -101,7 +122,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
||||
const remove = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, '');
|
||||
if (desktopKeyStorage) {
|
||||
await saveCloudKey(keyName, '');
|
||||
} else if (toolName) {
|
||||
await deleteToolCredential(toolName, keyName);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setValue('');
|
||||
setHasKey(false);
|
||||
setSaved(true);
|
||||
@@ -119,8 +146,8 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value.trim()) void save(value); }}
|
||||
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
|
||||
disabled={!desktopKeyStorage}
|
||||
placeholder={hasKey ? (desktopKeyStorage ? 'Saved in secure storage' : 'Saved by local server') : placeholder}
|
||||
disabled={!canManage}
|
||||
className="w-48 px-2 py-1 rounded text-xs"
|
||||
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
|
||||
{hasKey && (
|
||||
@@ -542,7 +569,7 @@ export function SettingsPage() {
|
||||
{/* Tools */}
|
||||
<Section title="Tools">
|
||||
<SettingRow label="Web Search" description="Tavily key for web search tool">
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." toolName="web_search" />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
@@ -805,7 +832,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
<div className="flex gap-3 mt-3 text-xs">
|
||||
<a
|
||||
href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"
|
||||
href="https://openjarvis.stanford.edu/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
|
||||
@@ -54,7 +54,15 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/v1': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
// ws: true is required for the /v1/agents/events WebSocket. Without it
|
||||
// Vite proxies the HTTP request but not the upgrade, so the socket never
|
||||
// opens — no error, no close event, just silence — and every live agent
|
||||
// view sits empty in dev while working in a production build.
|
||||
'/v1': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/health': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
'/api': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
},
|
||||
|
||||
@@ -196,6 +196,7 @@ nav:
|
||||
- Telemetry: user-guide/telemetry.md
|
||||
- Evaluations: user-guide/evaluations.md
|
||||
- Benchmarks: user-guide/benchmarks.md
|
||||
- System Access: user-guide/system-access.md
|
||||
- Security: user-guide/security.md
|
||||
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
|
||||
- Leaderboard: leaderboard.md
|
||||
|
||||
@@ -144,16 +144,21 @@ impl MemoryBackend for SQLiteMemory {
|
||||
) -> Result<Vec<RetrievalResult>, OpenJarvisError> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
// Split on any non-alphanumeric character (not just whitespace) so
|
||||
// internal punctuation — apostrophes in particular ("user's") — never
|
||||
// reaches the FTS5 MATCH string. FTS5's query grammar treats an
|
||||
// unescaped `'` as a string delimiter, so passing a raw token like
|
||||
// `user's` through silently fails to parse and yields zero rows with
|
||||
// no visible error. Splitting fully avoids needing to escape anything.
|
||||
let words: Vec<String> = query
|
||||
.split_whitespace()
|
||||
.map(|w| w.trim_matches(|c: char| "?.,!;:'\"()[]{}/ ".contains(c)).to_string())
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.map(|w| w.to_string())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect();
|
||||
let fts_query = if words.len() == 1 {
|
||||
words[0].clone()
|
||||
} else {
|
||||
words.join(" OR ")
|
||||
};
|
||||
if words.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let fts_query = words.join(" OR ");
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -320,6 +325,27 @@ mod tests {
|
||||
assert_eq!(mixed.len(), 2, "mixed-case query should find both documents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_apostrophe_in_query() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
mem.store("The user's name is Trev.", "identity", None).unwrap();
|
||||
|
||||
// A query containing an internal apostrophe must not break FTS5's
|
||||
// MATCH syntax (an unescaped `'` is a string delimiter in FTS5's
|
||||
// query grammar), which previously caused this to silently return
|
||||
// zero results instead of matching or erroring.
|
||||
let multi_word = mem.retrieve("what is the user's name", 5).unwrap();
|
||||
assert!(
|
||||
!multi_word.is_empty(),
|
||||
"query with an internal apostrophe should not silently return zero results"
|
||||
);
|
||||
|
||||
// Bare single-word possessive: exercises the (former) single-word
|
||||
// bypass path that skipped the OR-join entirely.
|
||||
let bare = mem.retrieve("user's", 5).unwrap();
|
||||
assert!(!bare.is_empty(), "single-word possessive query should still match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_scores_are_positive() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
|
||||
+10
-3
@@ -148,7 +148,8 @@ fi
|
||||
|
||||
# ── 7. Install Python dependencies ──────────────────────────────────
|
||||
info "Installing Python dependencies..."
|
||||
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
|
||||
uv sync --extra desktop --extra tools-search --quiet 2>/dev/null \
|
||||
|| uv sync --extra desktop --extra tools-search
|
||||
ok "Python dependencies installed"
|
||||
|
||||
# ── 7b. Build Rust extension ──────────────────────────────────────
|
||||
@@ -164,11 +165,17 @@ ok "Frontend dependencies installed"
|
||||
|
||||
# ── 9. Start backend ────────────────────────────────────────────────
|
||||
info "Starting backend API server on port 8000..."
|
||||
if curl -sf http://localhost:8000/health &>/dev/null; then
|
||||
fail "An OpenJarvis server is already running on port 8000. Stop it before re-running quickstart so updated environment variables are applied."
|
||||
fi
|
||||
uv run jarvis serve --port 8000 &>/dev/null &
|
||||
CLEANUP_PIDS+=($!)
|
||||
BACKEND_PID=$!
|
||||
CLEANUP_PIDS+=("$BACKEND_PID")
|
||||
sleep 3
|
||||
|
||||
if curl -sf http://localhost:8000/health &>/dev/null; then
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
fail "Backend exited during startup. Run 'uv run jarvis serve --port 8000' to see the error."
|
||||
elif curl -sf http://localhost:8000/health &>/dev/null; then
|
||||
ok "Backend running at http://localhost:8000"
|
||||
else
|
||||
warn "Backend may still be starting..."
|
||||
|
||||
@@ -57,6 +57,10 @@ class BaseAgent(ABC):
|
||||
|
||||
agent_id: str
|
||||
accepts_tools: bool = False
|
||||
# Plain conversational agents may opt into the managed runtime's generic
|
||||
# function-calling loop. Specialized agents keep their own execution
|
||||
# class even when process-wide MCP tools are available.
|
||||
supports_managed_tool_fallback: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.engine._base import looks_like_context_length_error
|
||||
|
||||
|
||||
class AgentTickError(Exception):
|
||||
"""Base class for agent tick errors."""
|
||||
@@ -64,6 +66,14 @@ def classify_error(exc: Exception) -> AgentTickError:
|
||||
|
||||
msg = str(exc).lower()
|
||||
|
||||
# A context-window overflow is deterministic: retrying the identical
|
||||
# over-length request can never succeed, so fail fast instead of burning
|
||||
# the retry budget on it.
|
||||
if getattr(exc, "is_context_length_error", False) or (
|
||||
looks_like_context_length_error(msg)
|
||||
):
|
||||
return FatalError(str(exc))
|
||||
|
||||
# Check fatal patterns first (more specific)
|
||||
if isinstance(exc, PermissionError):
|
||||
return FatalError(str(exc))
|
||||
@@ -90,6 +100,11 @@ def retry_delay(attempt: int) -> int:
|
||||
def suggest_action(error: AgentTickError) -> str:
|
||||
"""Return a human-readable suggested action for the given error."""
|
||||
msg = str(error).lower()
|
||||
if looks_like_context_length_error(msg):
|
||||
return (
|
||||
"Conversation too long for the model's context window \u2014 "
|
||||
"start a new chat or shorten the conversation"
|
||||
)
|
||||
if any(p in msg for p in ("rate limit", "rate_limit", "429", "too many requests")):
|
||||
return "Rate limited \u2014 agent will auto-retry on next tick"
|
||||
if any(p in msg for p in ("timeout", "timed out", "connection", "unavailable")):
|
||||
|
||||
+192
-109
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -14,6 +16,7 @@ from openjarvis.agents.errors import (
|
||||
classify_error,
|
||||
retry_delay,
|
||||
)
|
||||
from openjarvis.agents.tool_resolver import resolve_agent_tools
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -33,6 +36,32 @@ _MAX_RETRIES = 3
|
||||
_AGENT_TICK_DEFAULT_MODEL = "gemma4:31b"
|
||||
|
||||
|
||||
def _tool_calls_for_storage(result: AgentResult) -> list[dict[str, Any]] | None:
|
||||
"""Convert executor tool results to the managed-message storage contract."""
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
for tool_result in result.tool_results:
|
||||
metadata = getattr(tool_result, "metadata", {}) or {}
|
||||
arguments = metadata.get("arguments", "")
|
||||
if not isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.dumps(arguments, sort_keys=True)
|
||||
except (TypeError, ValueError):
|
||||
arguments = json.dumps(str(arguments))
|
||||
calls.append(
|
||||
{
|
||||
"tool": getattr(tool_result, "tool_name", ""),
|
||||
"arguments": arguments,
|
||||
"result": getattr(tool_result, "content", "") or "",
|
||||
"success": bool(getattr(tool_result, "success", False)),
|
||||
# SSE and the frontend persist/display latency in milliseconds.
|
||||
"latency": float(getattr(tool_result, "latency_seconds", 0.0) or 0.0)
|
||||
* 1000.0,
|
||||
}
|
||||
)
|
||||
return calls or None
|
||||
|
||||
|
||||
class AgentExecutor:
|
||||
"""Executes a single tick for a managed agent.
|
||||
|
||||
@@ -51,6 +80,7 @@ class AgentExecutor:
|
||||
self._manager = manager
|
||||
self._bus = event_bus
|
||||
self._trace_store = trace_store
|
||||
self._toolkit_local = threading.local()
|
||||
|
||||
def set_system(self, system: Any) -> None:
|
||||
"""Deferred system injection — called after JarvisSystem is constructed."""
|
||||
@@ -63,27 +93,6 @@ class AgentExecutor:
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
def _inject_tool_deps(self, tool: Any) -> None:
|
||||
"""Inject runtime dependencies into a tool instance.
|
||||
|
||||
Mirrors SystemBuilder._inject_tool_deps (system.py:920-945)
|
||||
but uses the lightweight system's references.
|
||||
"""
|
||||
if self._system is None:
|
||||
return
|
||||
name = getattr(getattr(tool, "spec", None), "name", "")
|
||||
if name == "llm":
|
||||
if hasattr(tool, "_engine"):
|
||||
tool._engine = self._system.engine
|
||||
if hasattr(tool, "_model"):
|
||||
tool._model = self._system.model
|
||||
elif name == "retrieval" or name.startswith("memory_"):
|
||||
if hasattr(tool, "_backend"):
|
||||
tool._backend = getattr(self._system, "memory_backend", None)
|
||||
elif name.startswith("channel_"):
|
||||
if hasattr(tool, "_channel"):
|
||||
tool._channel = getattr(self._system, "channel_backend", None)
|
||||
|
||||
def run_ephemeral(
|
||||
self,
|
||||
agent_type: str,
|
||||
@@ -102,9 +111,7 @@ class AgentExecutor:
|
||||
)
|
||||
return agent.run(input_text)
|
||||
|
||||
def execute_tick(
|
||||
self, agent_id: str, *, lock_already_held: bool = False
|
||||
) -> None:
|
||||
def execute_tick(self, agent_id: str, *, lock_already_held: bool = False) -> None:
|
||||
"""Run one tick for the given agent.
|
||||
|
||||
1. Acquire concurrency guard (start_tick)
|
||||
@@ -126,9 +133,7 @@ class AgentExecutor:
|
||||
self._manager.start_tick(agent_id)
|
||||
self._set_activity(agent_id, "Preparing tick...")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Agent %s already running, skipping tick", agent_id
|
||||
)
|
||||
logger.warning("Agent %s already running, skipping tick", agent_id)
|
||||
return
|
||||
|
||||
agent = self._manager.get_agent(agent_id)
|
||||
@@ -252,7 +257,20 @@ class AgentExecutor:
|
||||
raise last_error or FatalError("max retries exhausted")
|
||||
|
||||
def _invoke_agent(self, agent: dict) -> AgentResult:
|
||||
"""Invoke the actual agent run. Tests mock this method."""
|
||||
"""Invoke one agent while owning every resource its resolver opens."""
|
||||
|
||||
previous = getattr(self._toolkit_local, "current", None)
|
||||
self._toolkit_local.current = None
|
||||
try:
|
||||
return self._invoke_agent_impl(agent)
|
||||
finally:
|
||||
current = getattr(self._toolkit_local, "current", None)
|
||||
if current is not None:
|
||||
current.close()
|
||||
self._toolkit_local.current = previous
|
||||
|
||||
def _invoke_agent_impl(self, agent: dict) -> AgentResult:
|
||||
"""Implementation split out so the wrapper owns resolver lifetime."""
|
||||
from openjarvis.agents import AgentRegistry
|
||||
|
||||
agent_type = agent.get("agent_type", "monitor_operative")
|
||||
@@ -261,6 +279,10 @@ class AgentExecutor:
|
||||
raise FatalError(f"Unknown agent type: {agent_type}")
|
||||
|
||||
config = agent.get("config", {})
|
||||
agent_accepts_tools = bool(getattr(agent_cls, "accepts_tools", False))
|
||||
supports_tool_fallback = bool(
|
||||
getattr(agent_cls, "supports_managed_tool_fallback", False)
|
||||
)
|
||||
|
||||
# Resolve engine + model from JarvisSystem
|
||||
engine = self._system.engine if self._system else None
|
||||
@@ -304,64 +326,88 @@ class AgentExecutor:
|
||||
except Exception:
|
||||
pass # Fall back to configured model
|
||||
|
||||
# Resolve tools from config via ToolRegistry
|
||||
tool_names = config.get("tools", [])
|
||||
if isinstance(tool_names, str):
|
||||
tool_names = [t.strip() for t in tool_names.split(",") if t.strip()]
|
||||
mcp_tools: list[Any] = []
|
||||
mcp_clients: list[Any] = []
|
||||
if (
|
||||
config.get("mcp_tools", True) is not False
|
||||
and self._system is not None
|
||||
and (agent_accepts_tools or supports_tool_fallback)
|
||||
):
|
||||
provider = getattr(
|
||||
self._system,
|
||||
"get_managed_agent_mcp_tools",
|
||||
None,
|
||||
)
|
||||
if callable(provider):
|
||||
try:
|
||||
mcp_tools, mcp_clients = provider()
|
||||
except Exception as exc:
|
||||
logger.warning("Managed-agent MCP discovery failed: %s", exc)
|
||||
else:
|
||||
mcp_tools = list(getattr(self._system, "mcp_tools", []) or [])
|
||||
mcp_clients = list(getattr(self._system, "_mcp_clients", []) or [])
|
||||
|
||||
tool_instances: list[Any] = []
|
||||
if tool_names:
|
||||
try:
|
||||
from openjarvis.server.agent_manager_routes import (
|
||||
_ensure_registries_populated,
|
||||
)
|
||||
if not mcp_tools:
|
||||
try:
|
||||
from openjarvis.tools.mcp_adapter import MCPToolAdapter
|
||||
|
||||
_ensure_registries_populated()
|
||||
except ImportError:
|
||||
pass
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
pool = (
|
||||
getattr(
|
||||
getattr(self._system, "tool_executor", None),
|
||||
"_tools",
|
||||
{},
|
||||
)
|
||||
or {}
|
||||
)
|
||||
mcp_tools = [
|
||||
tool
|
||||
for tool in pool.values()
|
||||
if isinstance(tool, MCPToolAdapter)
|
||||
]
|
||||
except Exception:
|
||||
mcp_tools = []
|
||||
|
||||
for tname in tool_names:
|
||||
if ToolRegistry.contains(tname):
|
||||
try:
|
||||
tool_cls = ToolRegistry.get(tname)
|
||||
tool = tool_cls()
|
||||
self._inject_tool_deps(tool)
|
||||
tool_instances.append(tool)
|
||||
except Exception:
|
||||
logger.warning("Failed to instantiate tool %s", tname)
|
||||
resolved_toolkit = resolve_agent_tools(
|
||||
agent,
|
||||
engine=engine,
|
||||
model=model,
|
||||
memory_backend=getattr(self._system, "memory_backend", None),
|
||||
channel_backend=getattr(self._system, "channel_backend", None),
|
||||
mcp_tools=mcp_tools,
|
||||
mcp_clients=mcp_clients,
|
||||
knowledge_db_path=getattr(self._system, "knowledge_db_path", None),
|
||||
)
|
||||
self._toolkit_local.current = resolved_toolkit
|
||||
tool_instances = resolved_toolkit.instances
|
||||
logger.info(
|
||||
"Agent %s: resolved %d tools (%s)",
|
||||
agent["name"],
|
||||
len(tool_instances),
|
||||
", ".join(resolved_toolkit.by_name) or "none",
|
||||
)
|
||||
|
||||
# Pull tools already discovered by SystemBuilder (e.g. external MCP
|
||||
# adapters) that aren't in the static ToolRegistry. Without this,
|
||||
# agents declaring MCP-discovered tools in their template would
|
||||
# silently fall back to natives only.
|
||||
if (
|
||||
self._system is not None
|
||||
and getattr(self._system, "tool_executor", None) is not None
|
||||
):
|
||||
mcp_pool = getattr(self._system.tool_executor, "_tools", {}) or {}
|
||||
existing = {t.spec.name for t in tool_instances}
|
||||
for tname in tool_names:
|
||||
if tname in existing:
|
||||
continue
|
||||
pooled = mcp_pool.get(tname)
|
||||
if pooled is not None:
|
||||
tool_instances.append(pooled)
|
||||
execution_agent_cls = agent_cls
|
||||
if tool_instances and not agent_accepts_tools and supports_tool_fallback:
|
||||
# Managed SSE already runs configured tools through a native
|
||||
# function-calling loop regardless of the selected class. Use the
|
||||
# same capability for immediate/scheduled ticks instead of
|
||||
# silently discarding the resolved toolkit for SimpleAgent and
|
||||
# other explicitly compatible non-tool classes.
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
|
||||
if tool_instances:
|
||||
logger.info(
|
||||
"Agent %s: resolved %d/%d tools",
|
||||
agent["name"],
|
||||
len(tool_instances),
|
||||
len(tool_names),
|
||||
)
|
||||
execution_agent_cls = OrchestratorAgent
|
||||
logger.info(
|
||||
"Agent %s: %s does not accept tools; using %s for this "
|
||||
"tool-enabled tick",
|
||||
agent["name"],
|
||||
agent_cls.__name__,
|
||||
execution_agent_cls.__name__,
|
||||
)
|
||||
|
||||
# Construct agent instance
|
||||
agent_kwargs: dict[str, Any] = {}
|
||||
sys_prompt = config.get("system_prompt")
|
||||
if sys_prompt is not None:
|
||||
agent_kwargs["system_prompt"] = sys_prompt
|
||||
if getattr(agent_cls, "accepts_tools", False) and tool_instances:
|
||||
if getattr(execution_agent_cls, "accepts_tools", False) and tool_instances:
|
||||
agent_kwargs["tools"] = tool_instances
|
||||
# Hand the agent our EventBus so its ToolExecutor can publish
|
||||
# TOOL_CALL_START/END — without this, ToolExecutor's ``self._bus``
|
||||
@@ -383,7 +429,7 @@ class AgentExecutor:
|
||||
# recall / persistence paths.
|
||||
import inspect
|
||||
|
||||
init_sig = inspect.signature(agent_cls.__init__)
|
||||
init_sig = inspect.signature(execution_agent_cls.__init__)
|
||||
accepts_var_kw = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for p in init_sig.parameters.values()
|
||||
@@ -392,6 +438,16 @@ class AgentExecutor:
|
||||
def _accepts(name: str) -> bool:
|
||||
return accepts_var_kw or name in init_sig.parameters
|
||||
|
||||
# Unsupported kwargs used to trigger the broad TypeError fallback
|
||||
# below, which retried with a bare constructor and silently discarded
|
||||
# valid prompt/state wiring. Filter by the selected class's signature
|
||||
# before construction instead.
|
||||
if sys_prompt is not None and _accepts("system_prompt"):
|
||||
agent_kwargs["system_prompt"] = sys_prompt
|
||||
agent_kwargs = {
|
||||
name: value for name, value in agent_kwargs.items() if _accepts(name)
|
||||
}
|
||||
|
||||
state_kwargs: dict[str, Any] = {}
|
||||
if _accepts("operator_id"):
|
||||
state_kwargs["operator_id"] = agent["id"]
|
||||
@@ -408,27 +464,49 @@ class AgentExecutor:
|
||||
# agents, mirroring the one-shot `jarvis ask` path so they no
|
||||
# longer apply to CLI calls only (#376).
|
||||
cfg = getattr(self._system, "config", None)
|
||||
if cfg is not None and _accepts("prompt_builder"):
|
||||
if _accepts("prompt_builder") and (
|
||||
cfg is not None or sys_prompt is not None
|
||||
):
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
state_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=getattr(
|
||||
cfg.agent, "default_system_prompt", ""
|
||||
)
|
||||
or "",
|
||||
memory_files_config=cfg.memory_files,
|
||||
system_prompt_config=cfg.system_prompt,
|
||||
agent_template=(
|
||||
sys_prompt
|
||||
if sys_prompt is not None
|
||||
else getattr(
|
||||
getattr(cfg, "agent", None),
|
||||
"default_system_prompt",
|
||||
"",
|
||||
)
|
||||
or ""
|
||||
),
|
||||
memory_files_config=getattr(cfg, "memory_files", None),
|
||||
system_prompt_config=getattr(cfg, "system_prompt", None),
|
||||
)
|
||||
|
||||
try:
|
||||
agent_instance = agent_cls(
|
||||
engine, model, **agent_kwargs, **state_kwargs
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs)
|
||||
agent_instance = execution_agent_cls(
|
||||
engine,
|
||||
model,
|
||||
**agent_kwargs,
|
||||
**state_kwargs,
|
||||
)
|
||||
except TypeError:
|
||||
agent_instance = agent_cls(engine, model)
|
||||
try:
|
||||
agent_instance = execution_agent_cls(
|
||||
engine,
|
||||
model,
|
||||
**agent_kwargs,
|
||||
)
|
||||
except TypeError:
|
||||
agent_instance = execution_agent_cls(engine, model)
|
||||
except Exception:
|
||||
resolved_toolkit.close()
|
||||
raise
|
||||
|
||||
if resolved_toolkit.mcp_clients:
|
||||
agent_instance._mcp_clients = resolved_toolkit.mcp_clients
|
||||
|
||||
# Inject the managed-agent UUID into the agent's ToolExecutor so
|
||||
# emitted TOOL_CALL_START/END events carry it; the trace subscriber
|
||||
@@ -444,7 +522,7 @@ class AgentExecutor:
|
||||
agent["name"],
|
||||
len(tool_instances),
|
||||
", ".join(t.spec.name for t in tool_instances) or "none",
|
||||
agent_cls.__name__,
|
||||
execution_agent_cls.__name__,
|
||||
)
|
||||
|
||||
# Build input from instruction + summary_memory + pending messages.
|
||||
@@ -474,9 +552,7 @@ class AgentExecutor:
|
||||
tick_note = f"Previous tick: {first_sentence}"
|
||||
|
||||
if instruction:
|
||||
input_text = (
|
||||
f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
)
|
||||
input_text = f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
if tick_note:
|
||||
input_text += f"\n\n{tick_note}"
|
||||
else:
|
||||
@@ -561,21 +637,24 @@ class AgentExecutor:
|
||||
len(input_text),
|
||||
)
|
||||
_t0 = time.time()
|
||||
result = agent_instance.run(input_text, context=agent_ctx)
|
||||
|
||||
# Retry once if the model returned empty content (common with
|
||||
# Qwen3.5 thinking mode consuming all tokens).
|
||||
if not (result.content or "").strip():
|
||||
self._set_activity(
|
||||
agent["id"],
|
||||
"Retrying (empty response)...",
|
||||
)
|
||||
logger.warning(
|
||||
"Agent %s: empty content, retrying once",
|
||||
agent["name"],
|
||||
)
|
||||
try:
|
||||
result = agent_instance.run(input_text, context=agent_ctx)
|
||||
|
||||
# Retry once if the model returned empty content (common with
|
||||
# Qwen3.5 thinking mode consuming all tokens).
|
||||
if not (result.content or "").strip():
|
||||
self._set_activity(
|
||||
agent["id"],
|
||||
"Retrying (empty response)...",
|
||||
)
|
||||
logger.warning(
|
||||
"Agent %s: empty content, retrying once",
|
||||
agent["name"],
|
||||
)
|
||||
result = agent_instance.run(input_text, context=agent_ctx)
|
||||
finally:
|
||||
resolved_toolkit.close()
|
||||
|
||||
_elapsed = time.time() - _t0
|
||||
logger.info(
|
||||
"Agent %s: agent.run() completed in %.1fs, "
|
||||
@@ -665,7 +744,11 @@ class AgentExecutor:
|
||||
# message keeps the complete report. The old [:2000] slices
|
||||
# double-truncated and cut findings off mid-sentence.
|
||||
self._manager.update_summary_memory(agent_id, result.content)
|
||||
self._manager.store_agent_response(agent_id, result.content)
|
||||
self._manager.store_agent_response(
|
||||
agent_id,
|
||||
result.content,
|
||||
tool_calls=_tool_calls_for_storage(result),
|
||||
)
|
||||
|
||||
# Budget enforcement (post-tick check)
|
||||
agent_data = self._manager.get_agent(agent_id)
|
||||
|
||||
@@ -303,7 +303,9 @@ def _openrouter_limiter() -> _OpenRouterLimiter:
|
||||
if _OPENROUTER_LIMITER is None:
|
||||
with _OPENROUTER_LIMITER_LOCK:
|
||||
if _OPENROUTER_LIMITER is None:
|
||||
max_concurrent = int(os.environ.get("OJ_OPENROUTER_MAX_CONCURRENT", "20") or 20)
|
||||
max_concurrent = int(
|
||||
os.environ.get("OJ_OPENROUTER_MAX_CONCURRENT", "20") or 20
|
||||
)
|
||||
rpm = int(os.environ.get("OJ_OPENROUTER_RPM", "60") or 60)
|
||||
_OPENROUTER_LIMITER = _OpenRouterLimiter(max_concurrent, rpm)
|
||||
return _OPENROUTER_LIMITER
|
||||
@@ -319,8 +321,14 @@ def _serialize_block(block: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
out: Dict[str, Any] = {"type": getattr(block, "type", type(block).__name__)}
|
||||
for attr in (
|
||||
"id", "name", "input", "text", "thinking", "signature",
|
||||
"tool_use_id", "content",
|
||||
"id",
|
||||
"name",
|
||||
"input",
|
||||
"text",
|
||||
"thinking",
|
||||
"signature",
|
||||
"tool_use_id",
|
||||
"content",
|
||||
):
|
||||
if hasattr(block, attr):
|
||||
val = getattr(block, attr)
|
||||
@@ -341,14 +349,16 @@ def _serialize_openai_tool_calls(tool_calls: Any) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
for tc in tool_calls:
|
||||
fn = getattr(tc, "function", None)
|
||||
out.append({
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", "function"),
|
||||
"function": {
|
||||
"name": getattr(fn, "name", None) if fn else None,
|
||||
"arguments": getattr(fn, "arguments", None) if fn else None,
|
||||
},
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", "function"),
|
||||
"function": {
|
||||
"name": getattr(fn, "name", None) if fn else None,
|
||||
"arguments": getattr(fn, "arguments", None) if fn else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -473,32 +483,46 @@ class LocalCloudAgent(BaseAgent):
|
||||
srv = getattr(msg.usage, "server_tool_use", None)
|
||||
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
|
||||
content_blocks = [_serialize_block(b) for b in msg.content]
|
||||
tool_use_blocks = [b for b in content_blocks if b.get("type") in (
|
||||
"tool_use", "server_tool_use",
|
||||
)]
|
||||
tool_result_blocks = [b for b in content_blocks if b.get("type") in (
|
||||
"web_search_tool_result", "tool_result",
|
||||
)]
|
||||
_record_event({
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"output_config": output_config,
|
||||
"stop_reason": getattr(msg, "stop_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
tool_use_blocks = [
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type")
|
||||
in (
|
||||
"tool_use",
|
||||
"server_tool_use",
|
||||
)
|
||||
]
|
||||
tool_result_blocks = [
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type")
|
||||
in (
|
||||
"web_search_tool_result",
|
||||
"tool_result",
|
||||
)
|
||||
]
|
||||
_record_event(
|
||||
{
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"output_config": output_config,
|
||||
"stop_reason": getattr(msg, "stop_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, msg.usage.input_tokens, msg.usage.output_tokens, n_searches
|
||||
|
||||
@staticmethod
|
||||
@@ -550,24 +574,26 @@ class LocalCloudAgent(BaseAgent):
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "openai",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"response_format": response_format,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "openai",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"response_format": response_format,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -614,12 +640,10 @@ class LocalCloudAgent(BaseAgent):
|
||||
from openai import OpenAI
|
||||
|
||||
if model.startswith("openrouter/"):
|
||||
model = model[len("openrouter/"):]
|
||||
model = model[len("openrouter/") :]
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"OPENROUTER_API_KEY is not set; cannot call OpenRouter."
|
||||
)
|
||||
raise RuntimeError("OPENROUTER_API_KEY is not set; cannot call OpenRouter.")
|
||||
client = OpenAI(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=api_key,
|
||||
@@ -658,21 +682,23 @@ class LocalCloudAgent(BaseAgent):
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "openrouter",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "openrouter",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -701,7 +727,9 @@ class LocalCloudAgent(BaseAgent):
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(http_options=types.HttpOptions(timeout=int(timeout * 1000)))
|
||||
client = genai.Client(
|
||||
http_options=types.HttpOptions(timeout=int(timeout * 1000))
|
||||
)
|
||||
cfg = types.GenerateContentConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
@@ -728,21 +756,23 @@ class LocalCloudAgent(BaseAgent):
|
||||
finish_reason = str(resp.candidates[0].finish_reason)
|
||||
except Exception:
|
||||
pass
|
||||
_record_event({
|
||||
"kind": "gemini",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "gemini",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -797,27 +827,29 @@ class LocalCloudAgent(BaseAgent):
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "vllm",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"enable_thinking": enable_thinking,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "vllm",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"enable_thinking": enable_thinking,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -884,33 +916,37 @@ class LocalCloudAgent(BaseAgent):
|
||||
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
|
||||
content_blocks = [_serialize_block(b) for b in msg.content]
|
||||
tool_use_blocks = [
|
||||
b for b in content_blocks
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type") in ("tool_use", "server_tool_use")
|
||||
]
|
||||
tool_result_blocks = [
|
||||
b for b in content_blocks
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type") in ("web_search_tool_result", "tool_result")
|
||||
]
|
||||
stop_reason = getattr(msg, "stop_reason", None)
|
||||
_record_event({
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system if turn == 0 else None,
|
||||
"user": user if turn == 0 else None,
|
||||
"turn": turn,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"stop_reason": stop_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system if turn == 0 else None,
|
||||
"user": user if turn == 0 else None,
|
||||
"turn": turn,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"stop_reason": stop_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
p_total += msg.usage.input_tokens
|
||||
c_total += msg.usage.output_tokens
|
||||
n_searches_total += n_searches
|
||||
@@ -920,9 +956,7 @@ class LocalCloudAgent(BaseAgent):
|
||||
# here — break and let the caller (or future loop variant)
|
||||
# handle it. Only ``server_tool_use`` blocks (web_search)
|
||||
# are auto-continued by Anthropic itself.
|
||||
client_tool_use = any(
|
||||
b.get("type") == "tool_use" for b in content_blocks
|
||||
)
|
||||
client_tool_use = any(b.get("type") == "tool_use" for b in content_blocks)
|
||||
if client_tool_use:
|
||||
break
|
||||
if stop_reason == "end_turn" or stop_reason is None:
|
||||
@@ -931,10 +965,12 @@ class LocalCloudAgent(BaseAgent):
|
||||
# (server side) — Anthropic returned mid-thought. Append the
|
||||
# assistant turn and ask it to continue.
|
||||
messages.append({"role": "assistant", "content": msg.content})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "Continue.",
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Continue.",
|
||||
}
|
||||
)
|
||||
return last_text, p_total, c_total, n_searches_total, turns
|
||||
|
||||
@staticmethod
|
||||
@@ -1008,8 +1044,12 @@ class LocalCloudAgent(BaseAgent):
|
||||
continue
|
||||
raise
|
||||
if resp is None:
|
||||
raise last_exc if last_exc is not None else RuntimeError(
|
||||
"openai responses.create failed for all web_search tool names"
|
||||
raise (
|
||||
last_exc
|
||||
if last_exc is not None
|
||||
else RuntimeError(
|
||||
"openai responses.create failed for all web_search tool names"
|
||||
)
|
||||
)
|
||||
_bump_cloud_calls()
|
||||
latency = time.time() - t0
|
||||
@@ -1033,30 +1073,35 @@ class LocalCloudAgent(BaseAgent):
|
||||
text = "".join(chunks)
|
||||
|
||||
n_searches = sum(
|
||||
1 for item in output_items
|
||||
if getattr(item, "type", None) in (
|
||||
"web_search_call", "web_search_tool_call",
|
||||
1
|
||||
for item in output_items
|
||||
if getattr(item, "type", None)
|
||||
in (
|
||||
"web_search_call",
|
||||
"web_search_tool_call",
|
||||
)
|
||||
)
|
||||
u = getattr(resp, "usage", None)
|
||||
p = int(getattr(u, "input_tokens", 0) or 0) if u else 0
|
||||
c = int(getattr(u, "output_tokens", 0) or 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "openai_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"output_items": _jsonable(output_items),
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": [{"type": used_tool_name}],
|
||||
"stop_reason": getattr(resp, "status", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "openai_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"output_items": _jsonable(output_items),
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": [{"type": used_tool_name}],
|
||||
"stop_reason": getattr(resp, "status", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c, n_searches, 1
|
||||
|
||||
@staticmethod
|
||||
@@ -1129,23 +1174,25 @@ class LocalCloudAgent(BaseAgent):
|
||||
n_searches = len(web_search_queries)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_record_event({
|
||||
"kind": "gemini_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"web_search_queries": web_search_queries,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "gemini_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"web_search_queries": web_search_queries,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c, n_searches, 1
|
||||
|
||||
def _call_cloud(
|
||||
@@ -1269,8 +1316,13 @@ class LocalCloudAgent(BaseAgent):
|
||||
# Persist the trace before the trace state is closed (and even on
|
||||
# hard failure, so we get a record of what we did before it broke).
|
||||
self._write_trace_log(
|
||||
context, input, answer, meta if "meta" in locals() else {},
|
||||
events, soft_reason, exc_obj,
|
||||
context,
|
||||
input,
|
||||
answer,
|
||||
meta if "meta" in locals() else {},
|
||||
events,
|
||||
soft_reason,
|
||||
exc_obj,
|
||||
)
|
||||
_close_trace()
|
||||
_close_call_counts()
|
||||
@@ -1326,9 +1378,7 @@ class LocalCloudAgent(BaseAgent):
|
||||
"metadata": meta,
|
||||
"events": events,
|
||||
"soft_error": soft_reason,
|
||||
"error": (
|
||||
f"{type(exc).__name__}: {exc}" if exc is not None else None
|
||||
),
|
||||
"error": (f"{type(exc).__name__}: {exc}" if exc is not None else None),
|
||||
}
|
||||
(out_dir / f"{task_id}.json").write_text(
|
||||
json.dumps(blob, indent=2, default=str)
|
||||
|
||||
@@ -96,6 +96,7 @@ class EnergyCollector:
|
||||
return self
|
||||
try:
|
||||
import pynvml # type: ignore[import-not-found]
|
||||
|
||||
pynvml.nvmlInit()
|
||||
total = pynvml.nvmlDeviceGetCount()
|
||||
self.gpu_indices = _resolve_gpu_indices(total)
|
||||
|
||||
@@ -174,12 +174,15 @@ def _is_retryable(exc: BaseException) -> bool:
|
||||
import openai
|
||||
except ImportError:
|
||||
return False
|
||||
if isinstance(exc, (
|
||||
openai.RateLimitError,
|
||||
openai.APITimeoutError,
|
||||
openai.APIConnectionError,
|
||||
openai.InternalServerError,
|
||||
)):
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
openai.RateLimitError,
|
||||
openai.APITimeoutError,
|
||||
openai.APIConnectionError,
|
||||
openai.InternalServerError,
|
||||
),
|
||||
):
|
||||
return True
|
||||
if isinstance(exc, openai.APIStatusError):
|
||||
status = getattr(exc, "status_code", None)
|
||||
@@ -196,7 +199,7 @@ def _sleep_for(attempt: int, exc: BaseException) -> float:
|
||||
# Respect a server-provided hint, but clamp to our cap so a
|
||||
# pathological header can't stall the run for hours.
|
||||
return min(_RETRY_CAP, hinted) + random.uniform(0, 0.5)
|
||||
base = min(_RETRY_CAP, _RETRY_BASE * (2 ** attempt))
|
||||
base = min(_RETRY_CAP, _RETRY_BASE * (2**attempt))
|
||||
# Full jitter — better tail behavior than equal jitter when many
|
||||
# workers wake at the same moment.
|
||||
return random.uniform(0.0, base)
|
||||
@@ -237,16 +240,19 @@ def _wrap_create(orig: Callable[..., Any]) -> Callable[..., Any]:
|
||||
import openai
|
||||
except ImportError:
|
||||
raise
|
||||
if not isinstance(exc, (
|
||||
openai.APIConnectionError,
|
||||
openai.APITimeoutError,
|
||||
openai.InternalServerError,
|
||||
)):
|
||||
if not isinstance(
|
||||
exc,
|
||||
(
|
||||
openai.APIConnectionError,
|
||||
openai.APITimeoutError,
|
||||
openai.InternalServerError,
|
||||
),
|
||||
):
|
||||
raise
|
||||
local_last_exc = exc
|
||||
if attempt >= 2:
|
||||
break
|
||||
time.sleep(2 ** attempt)
|
||||
time.sleep(2**attempt)
|
||||
assert local_last_exc is not None
|
||||
raise local_last_exc
|
||||
|
||||
@@ -266,6 +272,7 @@ def _wrap_create(orig: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# stay parseable in the runner log.
|
||||
try:
|
||||
import sys
|
||||
|
||||
print(
|
||||
f"[openai-retry] attempt {attempt + 1}/{_MAX_RETRIES} "
|
||||
f"{type(exc).__name__}: {str(exc)[:120]} — "
|
||||
@@ -333,9 +340,7 @@ def patch_openai_globally() -> None:
|
||||
from openai.resources.chat import completions as _comp_mod_async
|
||||
|
||||
cls = getattr(_comp_mod_async, "AsyncCompletions", None)
|
||||
if cls is not None and not getattr(
|
||||
cls.create, "_hybrid_patched", False
|
||||
):
|
||||
if cls is not None and not getattr(cls.create, "_hybrid_patched", False):
|
||||
# Async wrapper is structurally different — only patch
|
||||
# the bumped defaults via __init__; full retry loop on
|
||||
# async would need an async wrapper. Leave that for the
|
||||
|
||||
@@ -11,27 +11,27 @@ from __future__ import annotations
|
||||
|
||||
# USD per million tokens, (input, output). Local models = 0.
|
||||
PRICES: dict[str, tuple[float, float]] = {
|
||||
"claude-opus-4-7": (5.00, 25.0),
|
||||
"claude-sonnet-4-6": (3.00, 15.0),
|
||||
"claude-haiku-4-5": (1.00, 5.00),
|
||||
"claude-haiku-4-5-20251001": (1.00, 5.00),
|
||||
"gpt-5.5": (5.00, 30.0),
|
||||
"gpt-5": (1.25, 10.0),
|
||||
"gpt-5-mini": (0.25, 2.00),
|
||||
"gpt-5-mini-2025-08-07": (0.25, 2.00),
|
||||
"gpt-4o": (0.15, 0.60),
|
||||
"claude-opus-4-7": (5.00, 25.0),
|
||||
"claude-sonnet-4-6": (3.00, 15.0),
|
||||
"claude-haiku-4-5": (1.00, 5.00),
|
||||
"claude-haiku-4-5-20251001": (1.00, 5.00),
|
||||
"gpt-5.5": (5.00, 30.0),
|
||||
"gpt-5": (1.25, 10.0),
|
||||
"gpt-5-mini": (0.25, 2.00),
|
||||
"gpt-5-mini-2025-08-07": (0.25, 2.00),
|
||||
"gpt-4o": (0.15, 0.60),
|
||||
# Gemini Developer API prices (USD per 1M tokens). Pro models use tiered
|
||||
# pricing above 200K prompt tokens; GAIA prompts stay under that tier, so
|
||||
# charge the low-context standard rate.
|
||||
"gemini-3.1-pro-preview": (2.00, 12.0),
|
||||
"gemini-3.1-pro-preview": (2.00, 12.0),
|
||||
"gemini-3.1-pro-preview-customtools": (2.00, 12.0),
|
||||
"gemini-2.5-pro": (1.25, 10.0),
|
||||
"gemini-2.5-flash": (0.30, 2.50),
|
||||
"gemini-2.5-flash-lite": (0.10, 0.40),
|
||||
"gemini-2.5-pro": (1.25, 10.0),
|
||||
"gemini-2.5-flash": (0.30, 2.50),
|
||||
"gemini-2.5-flash-lite": (0.10, 0.40),
|
||||
# OpenRouter slugs (used by toolorchestra paper-match pool).
|
||||
# Prices are OpenRouter list (USD/1M tokens), 2026-05 snapshot.
|
||||
"qwen/qwen-2.5-coder-32b-instruct": (0.08, 0.18),
|
||||
"qwen/qwen3-32b": (0.10, 0.30),
|
||||
"qwen/qwen-2.5-coder-32b-instruct": (0.08, 0.18),
|
||||
"qwen/qwen3-32b": (0.10, 0.30),
|
||||
"meta-llama/llama-3.3-70b-instruct": (0.13, 0.39),
|
||||
}
|
||||
|
||||
@@ -64,11 +64,7 @@ def is_reasoning_model(model: str) -> bool:
|
||||
before emitting visible answer text. At max_tokens=4096 these silently
|
||||
truncate with empty answers on GAIA (26/100 GPT-5, 18/100 Gemini Pro)."""
|
||||
m = (model or "").lower()
|
||||
return (
|
||||
is_gpt5_family(model)
|
||||
or "gemini-2.5-pro" in m
|
||||
or "gemini-3.1-pro" in m
|
||||
)
|
||||
return is_gpt5_family(model) or "gemini-2.5-pro" in m or "gemini-3.1-pro" in m
|
||||
|
||||
|
||||
def default_max_output_tokens(model: str) -> int:
|
||||
|
||||
@@ -80,9 +80,7 @@ def _resolve_local_model(endpoint: str, registry_model: str) -> str:
|
||||
a model id (e.g. ``Qwen3.5-9B``) that's different from what's loaded.
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
endpoint.rstrip("/") + "/models", timeout=5
|
||||
) as r:
|
||||
with urllib.request.urlopen(endpoint.rstrip("/") + "/models", timeout=5) as r:
|
||||
data = json.loads(r.read())
|
||||
served = [m["id"] for m in data.get("data", [])]
|
||||
except Exception:
|
||||
@@ -158,14 +156,15 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
# only the cloud executor passes do. With web_search on, dispatch
|
||||
# to the search-capable agent loop for the configured provider.
|
||||
if use_ws:
|
||||
(initial_resp, e1_in, e1_out, n_s1, e1_turns,
|
||||
e1_search_cost) = self._executor_search(
|
||||
user=f"Question:\n{question}",
|
||||
system=EXECUTOR_INITIAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
(initial_resp, e1_in, e1_out, n_s1, e1_turns, e1_search_cost) = (
|
||||
self._executor_search(
|
||||
user=f"Question:\n{question}",
|
||||
system=EXECUTOR_INITIAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
)
|
||||
n_searches_total += n_s1
|
||||
search_cost_total += e1_search_cost
|
||||
@@ -186,7 +185,8 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
)
|
||||
local_model = _resolve_local_model(self._local_endpoint, self._local_model)
|
||||
advisor_prompt = ADVISOR_TEMPLATE.format(
|
||||
question=question, initial_response=initial_resp,
|
||||
question=question,
|
||||
initial_response=initial_resp,
|
||||
)
|
||||
advisor_text, adv_in, adv_out = self._call_vllm(
|
||||
local_model,
|
||||
@@ -206,14 +206,15 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
f"answer-format rules."
|
||||
)
|
||||
if use_ws:
|
||||
(final_answer, e2_in, e2_out, n_s2, e2_turns,
|
||||
e2_search_cost) = self._executor_search(
|
||||
user=final_user,
|
||||
system=EXECUTOR_FINAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
(final_answer, e2_in, e2_out, n_s2, e2_turns, e2_search_cost) = (
|
||||
self._executor_search(
|
||||
user=final_user,
|
||||
system=EXECUTOR_FINAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
)
|
||||
n_searches_total += n_s2
|
||||
search_cost_total += e2_search_cost
|
||||
@@ -424,8 +425,10 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
|
||||
tokens_local = adv_in + adv_out
|
||||
tokens_cloud = (
|
||||
initial_out["tokens_in"] + initial_out["tokens_out"]
|
||||
+ final_out["tokens_in"] + final_out["tokens_out"]
|
||||
initial_out["tokens_in"]
|
||||
+ initial_out["tokens_out"]
|
||||
+ final_out["tokens_in"]
|
||||
+ final_out["tokens_out"]
|
||||
)
|
||||
cost = initial_out["cost_usd"] + final_out["cost_usd"]
|
||||
meta: Dict[str, Any] = {
|
||||
|
||||
@@ -73,6 +73,7 @@ ARCHON_SWE_RANKER_SYS = (
|
||||
|
||||
# ---------- Stubs for Archon's eager-imported heavy deps we don't need ----------
|
||||
|
||||
|
||||
def _stub_archon_imports() -> None:
|
||||
"""``utils.py`` imports groq/google/litellm/dotenv at module load. Stub
|
||||
the ones we don't use so the import chain doesn't fail when those
|
||||
@@ -97,6 +98,7 @@ def _add_archon_to_path() -> None:
|
||||
|
||||
# ---------- Anthropic patch for Opus 4.7 ----------
|
||||
|
||||
|
||||
def _patch_anthropic_for_opus() -> None:
|
||||
from anthropic.resources.messages import messages as _msgs_mod
|
||||
|
||||
@@ -129,8 +131,10 @@ def _tally() -> Dict[str, int]:
|
||||
counts = getattr(_TALLY_LOCAL, "counts", None)
|
||||
if counts is None:
|
||||
counts = {
|
||||
"cloud_prompt": 0, "cloud_completion": 0,
|
||||
"local_prompt": 0, "local_completion": 0,
|
||||
"cloud_prompt": 0,
|
||||
"cloud_completion": 0,
|
||||
"local_prompt": 0,
|
||||
"local_completion": 0,
|
||||
"n_web_searches": 0,
|
||||
}
|
||||
_TALLY_LOCAL.counts = counts
|
||||
@@ -141,8 +145,10 @@ def _tally() -> Dict[str, int]:
|
||||
|
||||
def _reset_tally() -> None:
|
||||
_TALLY_LOCAL.counts = {
|
||||
"cloud_prompt": 0, "cloud_completion": 0,
|
||||
"local_prompt": 0, "local_completion": 0,
|
||||
"cloud_prompt": 0,
|
||||
"cloud_completion": 0,
|
||||
"local_prompt": 0,
|
||||
"local_completion": 0,
|
||||
"n_web_searches": 0,
|
||||
}
|
||||
|
||||
@@ -169,6 +175,7 @@ def _make_local_generator(local_endpoint: str, local_model: str):
|
||||
|
||||
def local_gen(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
|
||||
import time as _time
|
||||
|
||||
t0 = _time.time()
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
@@ -179,31 +186,35 @@ def _make_local_generator(local_endpoint: str, local_model: str):
|
||||
)
|
||||
_bump_local_calls()
|
||||
except Exception as e:
|
||||
_record_event({
|
||||
"kind": "archon_local_gen_error",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"error": f"{type(e).__name__}: {e}",
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_local_gen_error",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"error": f"{type(e).__name__}: {e}",
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return f"[local-vllm error: {e!r}]"
|
||||
u = resp.usage
|
||||
if u:
|
||||
_tally()["local_prompt"] += getattr(u, "prompt_tokens", 0) or 0
|
||||
_tally()["local_completion"] += getattr(u, "completion_tokens", 0) or 0
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
_record_event({
|
||||
"kind": "archon_local_gen",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_local_gen",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return text
|
||||
|
||||
return local_gen
|
||||
@@ -218,10 +229,13 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
|
||||
def gen_openai(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
|
||||
import time as _time
|
||||
|
||||
client = _OAI()
|
||||
kwargs: Dict[str, Any] = dict(
|
||||
model=model, messages=messages,
|
||||
max_tokens=max_tokens, temperature=temperature,
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
# GPT-5/o1/o3 reject non-default temperature and use max_completion_tokens.
|
||||
if model.startswith(("gpt-5", "o1", "o3")):
|
||||
@@ -236,20 +250,23 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
_tally()["cloud_prompt"] += getattr(u, "prompt_tokens", 0) or 0
|
||||
_tally()["cloud_completion"] += getattr(u, "completion_tokens", 0) or 0
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
_record_event({
|
||||
"kind": "archon_cloud_openai",
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_cloud_openai",
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return text
|
||||
|
||||
def gen_anthropic(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
|
||||
import time as _time
|
||||
|
||||
client = _anth.Anthropic(timeout=600.0)
|
||||
system = ""
|
||||
msgs = []
|
||||
@@ -259,7 +276,10 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
else:
|
||||
msgs.append(m)
|
||||
kwargs: Dict[str, Any] = dict(
|
||||
model=model, system=system, messages=msgs, max_tokens=max_tokens,
|
||||
model=model,
|
||||
system=system,
|
||||
messages=msgs,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
if not model.startswith(NO_TEMP_PREFIXES):
|
||||
kwargs["temperature"] = temperature
|
||||
@@ -277,24 +297,27 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
srv = getattr(u, "server_tool_use", None) if u else None
|
||||
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
|
||||
_tally()["n_web_searches"] += int(n_searches)
|
||||
_record_event({
|
||||
"kind": "archon_cloud_anthropic",
|
||||
"model": model,
|
||||
"system": system,
|
||||
"messages": msgs,
|
||||
"response": text.strip(),
|
||||
"tokens_in": getattr(u, "input_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "output_tokens", 0) if u else 0,
|
||||
"n_web_searches": int(n_searches),
|
||||
"tools_declared": kwargs.get("tools"),
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_cloud_anthropic",
|
||||
"model": model,
|
||||
"system": system,
|
||||
"messages": msgs,
|
||||
"response": text.strip(),
|
||||
"tokens_in": getattr(u, "input_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "output_tokens", 0) if u else 0,
|
||||
"n_web_searches": int(n_searches),
|
||||
"tools_declared": kwargs.get("tools"),
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return text.strip()
|
||||
|
||||
from archon.completions.components.Generator import (
|
||||
GENERATE_MAP as _GMAP, # type: ignore[import-not-found]
|
||||
)
|
||||
|
||||
_GMAP["OpenAI_API"] = gen_openai
|
||||
_GMAP["Anthropic_API"] = gen_anthropic
|
||||
|
||||
@@ -330,7 +353,9 @@ def _patch_archon_prompts() -> None:
|
||||
orig = _p.make_fuser_prompt
|
||||
|
||||
def patched(conv, references, critiques=None, length_control=False): # type: ignore[no-untyped-def]
|
||||
base = orig(conv, references, critiques=critiques, length_control=length_control)
|
||||
base = orig(
|
||||
conv, references, critiques=critiques, length_control=length_control
|
||||
)
|
||||
return base + _FUSER_FORMAT_REMINDER
|
||||
|
||||
patched._hybrid_format_patched = True # type: ignore[attr-defined]
|
||||
@@ -339,6 +364,7 @@ def _patch_archon_prompts() -> None:
|
||||
from archon.completions.components import (
|
||||
Fuser as _F, # type: ignore[import-not-found]
|
||||
)
|
||||
|
||||
_F.make_fuser_prompt = patched
|
||||
|
||||
|
||||
@@ -354,6 +380,7 @@ def _apply_patches_once() -> None:
|
||||
_patch_anthropic_for_opus()
|
||||
# Trigger Archon imports so GENERATE_MAP exists.
|
||||
import archon.completions.components.Generator # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
_wrap_archon_cloud_generators()
|
||||
_patch_archon_prompts()
|
||||
_PATCHES_APPLIED = True
|
||||
@@ -361,49 +388,62 @@ def _apply_patches_once() -> None:
|
||||
|
||||
# ---------- Architecture presets ----------
|
||||
|
||||
|
||||
def _presets():
|
||||
return {
|
||||
"ensemble_rank_fuse": lambda K, local_model, ranker_model, fuser_model, max_tokens, temperature: [
|
||||
[{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": K,
|
||||
}],
|
||||
[{
|
||||
"type": "ranker",
|
||||
"model": ranker_model,
|
||||
"model_type": "Anthropic_API" if ranker_model.startswith("claude") else "OpenAI_API",
|
||||
"top_k": min(K, 5),
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
}],
|
||||
[{
|
||||
"type": "fuser",
|
||||
"model": fuser_model,
|
||||
"model_type": "Anthropic_API" if fuser_model.startswith("claude") else "OpenAI_API",
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}],
|
||||
[
|
||||
{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": K,
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "ranker",
|
||||
"model": ranker_model,
|
||||
"model_type": "Anthropic_API"
|
||||
if ranker_model.startswith("claude")
|
||||
else "OpenAI_API",
|
||||
"top_k": min(K, 5),
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "fuser",
|
||||
"model": fuser_model,
|
||||
"model_type": "Anthropic_API"
|
||||
if fuser_model.startswith("claude")
|
||||
else "OpenAI_API",
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}
|
||||
],
|
||||
],
|
||||
# ``single_local`` honors the cfg ``max_tokens`` (passed positionally
|
||||
# like ``ensemble_rank_fuse``). Previously it hard-coded 2048, which
|
||||
# cut Qwen off mid-reasoning before it could emit the GAIA
|
||||
# ``FINAL ANSWER:`` line — the scorer then had nothing to extract.
|
||||
"single_local": lambda K, local_model, ranker_model, fuser_model, max_tokens, temperature: [
|
||||
[{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}],
|
||||
[
|
||||
{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
@@ -464,7 +504,12 @@ class ArchonAgent(LocalCloudAgent):
|
||||
)
|
||||
|
||||
layers = presets[arch](
|
||||
K, self._local_model, ranker_model, fuser_model, max_tokens, temperature,
|
||||
K,
|
||||
self._local_model,
|
||||
ranker_model,
|
||||
fuser_model,
|
||||
max_tokens,
|
||||
temperature,
|
||||
)
|
||||
archon_cfg = {"name": f"hybrid-archon-{arch}", "layers": layers}
|
||||
|
||||
@@ -480,10 +525,12 @@ class ArchonAgent(LocalCloudAgent):
|
||||
archon = Archon(archon_cfg)
|
||||
|
||||
try:
|
||||
answer = archon.generate([
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": input},
|
||||
])
|
||||
answer = archon.generate(
|
||||
[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": input},
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
# Re-raise so the base ``run()`` / runner's ``_run_one_inner``
|
||||
# records this in the row's ``error`` field instead of stashing
|
||||
@@ -518,10 +565,10 @@ class ArchonAgent(LocalCloudAgent):
|
||||
"tool_calls": int(n_searches),
|
||||
"traces": {
|
||||
"architecture": arch,
|
||||
"n_samples": K,
|
||||
"n_samples": K,
|
||||
"ranker_model": ranker_model,
|
||||
"fuser_model": fuser_model,
|
||||
"local_model": self._local_model,
|
||||
"fuser_model": fuser_model,
|
||||
"local_model": self._local_model,
|
||||
"tokens_breakdown": dict(_tally()),
|
||||
"web_search_enabled": ws_enabled,
|
||||
"n_web_searches": n_searches,
|
||||
@@ -568,26 +615,30 @@ class ArchonAgent(LocalCloudAgent):
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=f"archon_gen{k}",
|
||||
)
|
||||
candidates.append({
|
||||
"idx": k,
|
||||
"summary": out["final_summary"],
|
||||
"patch": out["patch"],
|
||||
"framed": out["answer"],
|
||||
"tokens_in": out["tokens_in"],
|
||||
"tokens_out": out["tokens_out"],
|
||||
"turns": out["turns"],
|
||||
})
|
||||
candidates.append(
|
||||
{
|
||||
"idx": k,
|
||||
"summary": out["final_summary"],
|
||||
"patch": out["patch"],
|
||||
"framed": out["answer"],
|
||||
"tokens_in": out["tokens_in"],
|
||||
"tokens_out": out["tokens_out"],
|
||||
"turns": out["turns"],
|
||||
}
|
||||
)
|
||||
total_tokens_local += out["tokens_in"] + out["tokens_out"]
|
||||
self.record_trace_event({
|
||||
"kind": "archon_swe_candidate",
|
||||
"idx": k,
|
||||
"patch_chars": len(out["patch"]),
|
||||
"summary": out["final_summary"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "archon_swe_candidate",
|
||||
"idx": k,
|
||||
"patch_chars": len(out["patch"]),
|
||||
"summary": out["final_summary"],
|
||||
}
|
||||
)
|
||||
|
||||
# Ranker: cloud picks the best candidate.
|
||||
ranker_user = (
|
||||
f"Issue:\n{task.get('problem_statement','')}\n\n"
|
||||
f"Issue:\n{task.get('problem_statement', '')}\n\n"
|
||||
f"K = {K} candidate patches:\n\n"
|
||||
+ "\n\n".join(
|
||||
f"=== Candidate {c['idx']} ===\nSummary: {c['summary']}\n"
|
||||
@@ -615,13 +666,15 @@ class ArchonAgent(LocalCloudAgent):
|
||||
chosen_idx = 0
|
||||
chosen = candidates[chosen_idx]
|
||||
|
||||
self.record_trace_event({
|
||||
"kind": "archon_swe_rank",
|
||||
"chosen_idx": chosen_idx,
|
||||
"ranker_raw": ranker_text,
|
||||
"tokens_in": r_in,
|
||||
"tokens_out": r_out,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "archon_swe_rank",
|
||||
"chosen_idx": chosen_idx,
|
||||
"ranker_raw": ranker_text,
|
||||
"tokens_in": r_in,
|
||||
"tokens_out": r_out,
|
||||
}
|
||||
)
|
||||
|
||||
meta = {
|
||||
"tokens_local": total_tokens_local,
|
||||
@@ -635,8 +688,12 @@ class ArchonAgent(LocalCloudAgent):
|
||||
"swe_mode": True,
|
||||
"K": K,
|
||||
"candidates": [
|
||||
{"idx": c["idx"], "summary": c["summary"],
|
||||
"patch_chars": len(c["patch"]), "turns": c["turns"]}
|
||||
{
|
||||
"idx": c["idx"],
|
||||
"summary": c["summary"],
|
||||
"patch_chars": len(c["patch"]),
|
||||
"turns": c["turns"],
|
||||
}
|
||||
for c in candidates
|
||||
],
|
||||
"chosen_idx": chosen_idx,
|
||||
|
||||
@@ -82,7 +82,11 @@ class BaselineCloudAgent(LocalCloudAgent):
|
||||
max_turns=int(cfg.get("swe_max_turns", 30)),
|
||||
bash_timeout=int(cfg.get("swe_bash_timeout_s", 120)),
|
||||
output_cap=int(cfg.get("swe_output_cap", 10_000)),
|
||||
turn_max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
|
||||
turn_max_tokens=int(
|
||||
cfg.get(
|
||||
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
),
|
||||
trace_prefix="baseline_cloud",
|
||||
)
|
||||
meta = {
|
||||
@@ -112,7 +116,11 @@ class BaselineCloudAgent(LocalCloudAgent):
|
||||
text, p_tok, c_tok, n_searches, turns = self._call_anthropic_agent(
|
||||
self._cloud_model,
|
||||
user=input,
|
||||
max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
|
||||
max_tokens=int(
|
||||
cfg.get(
|
||||
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
),
|
||||
temperature=0.0,
|
||||
tools=[build_web_search_tool(ws_max_uses)],
|
||||
max_turns=gaia_max_turns,
|
||||
@@ -145,17 +153,23 @@ class BaselineCloudAgent(LocalCloudAgent):
|
||||
# wired here. Skip cleanly rather than fake one. Cells that
|
||||
# want web_search must run on Anthropic until those backends
|
||||
# are wired.
|
||||
self.record_trace_event({
|
||||
"kind": "web_search_skipped",
|
||||
"reason": "non_anthropic_endpoint",
|
||||
"endpoint": self._cloud_endpoint,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "web_search_skipped",
|
||||
"reason": "non_anthropic_endpoint",
|
||||
"endpoint": self._cloud_endpoint,
|
||||
}
|
||||
)
|
||||
|
||||
# One-shot direct cloud call. GAIA only — SWE goes through the
|
||||
# mini-SWE-agent loop above (now supports anthropic/openai/gemini).
|
||||
text, p_tok, c_tok = self._call_cloud(
|
||||
user=input,
|
||||
max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
|
||||
max_tokens=int(
|
||||
cfg.get(
|
||||
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
),
|
||||
temperature=0.0,
|
||||
)
|
||||
meta = {
|
||||
|
||||
@@ -89,19 +89,20 @@ CONDUCTOR_STRICTER = (
|
||||
"Your previous response was not valid JSON or was missing required fields. "
|
||||
"Reply with ONLY a single JSON object — no prose, no code fences, no commentary "
|
||||
"— containing exactly the three keys model_id (list[int]), subtasks (list[str]), "
|
||||
"and access_list (list[list[int] or \"all\"]) of equal length, at most 5 entries, "
|
||||
'and access_list (list[list[int] or "all"]) of equal length, at most 5 entries, '
|
||||
"and access_list[0] must be [] (an empty list)."
|
||||
)
|
||||
|
||||
|
||||
# ---------- Plan parsing ----------
|
||||
|
||||
|
||||
def _strip_fences(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
first_nl = s.find("\n")
|
||||
if first_nl != -1:
|
||||
s = s[first_nl + 1:]
|
||||
s = s[first_nl + 1 :]
|
||||
if s.endswith("```"):
|
||||
s = s[:-3]
|
||||
s = s.strip()
|
||||
@@ -119,9 +120,7 @@ def _try_literal(s: str):
|
||||
"""Fallback for the paper's literal Python-list output style."""
|
||||
out = {}
|
||||
for key in ("model_id", "subtasks", "access_list"):
|
||||
m = re.search(
|
||||
rf"{key}\s*=\s*(\[[^\]]*\](?:\s*\+\s*\[[^\]]*\])*)", s, re.DOTALL
|
||||
)
|
||||
m = re.search(rf"{key}\s*=\s*(\[[^\]]*\](?:\s*\+\s*\[[^\]]*\])*)", s, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
@@ -154,7 +153,7 @@ def _validate_plan(plan: Any, n_workers: int) -> Optional[str]:
|
||||
if a == "all":
|
||||
continue
|
||||
if not isinstance(a, list):
|
||||
return f"access_list[{i}] must be list or \"all\""
|
||||
return f'access_list[{i}] must be list or "all"'
|
||||
for j in a:
|
||||
if not isinstance(j, int) or not (0 <= j < i):
|
||||
return f"access_list[{i}] has bad ref {j!r}"
|
||||
@@ -174,17 +173,18 @@ def _parse_plan(text: str, n_workers: int):
|
||||
|
||||
# ---------- Worker pool ----------
|
||||
|
||||
|
||||
def _vllm_alive(base_url: str) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
base_url.rstrip("/") + "/models", timeout=3
|
||||
) as r:
|
||||
with urllib.request.urlopen(base_url.rstrip("/") + "/models", timeout=3) as r:
|
||||
return r.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _default_pool(local_model: Optional[str], local_endpoint: Optional[str]) -> List[Dict[str, Any]]:
|
||||
def _default_pool(
|
||||
local_model: Optional[str], local_endpoint: Optional[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Default worker pool — faithful to the Sakana Conductor paper (arXiv 2512.04388).
|
||||
|
||||
The paper composes a heterogeneous 7-worker pool spanning three frontier
|
||||
@@ -206,98 +206,112 @@ def _default_pool(local_model: Optional[str], local_endpoint: Optional[str]) ->
|
||||
del local_model, local_endpoint # paper default carries no local worker
|
||||
pool: List[Dict[str, Any]] = []
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_GEMINI"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "gemini-pro",
|
||||
"endpoint": "gemini",
|
||||
"model": "gemini-2.5-pro",
|
||||
"description": (
|
||||
"Google Gemini 2.5 Pro. Frontier multimodal reasoner with a "
|
||||
"very large context window. Strong at long-document synthesis, "
|
||||
"multi-hop factual reasoning, and tasks that benefit from "
|
||||
"wide retrieval. Slower and pricier than mid-tier workers."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "gemini-pro",
|
||||
"endpoint": "gemini",
|
||||
"model": "gemini-2.5-pro",
|
||||
"description": (
|
||||
"Google Gemini 2.5 Pro. Frontier multimodal reasoner with a "
|
||||
"very large context window. Strong at long-document synthesis, "
|
||||
"multi-hop factual reasoning, and tasks that benefit from "
|
||||
"wide retrieval. Slower and pricier than mid-tier workers."
|
||||
),
|
||||
}
|
||||
)
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_ANTHROPIC"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "claude-sonnet-4",
|
||||
"endpoint": "anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"description": (
|
||||
"Anthropic Claude Sonnet 4. Strong general-purpose reasoner "
|
||||
"with careful instruction following and reliable formatting. "
|
||||
"Good default for code, structured writing, and decisive "
|
||||
"steps where accuracy matters more than raw throughput."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "claude-sonnet-4",
|
||||
"endpoint": "anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"description": (
|
||||
"Anthropic Claude Sonnet 4. Strong general-purpose reasoner "
|
||||
"with careful instruction following and reliable formatting. "
|
||||
"Good default for code, structured writing, and decisive "
|
||||
"steps where accuracy matters more than raw throughput."
|
||||
),
|
||||
}
|
||||
)
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_OPENAI"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "gpt-5",
|
||||
"endpoint": "openai",
|
||||
"model": "gpt-5",
|
||||
"description": (
|
||||
"OpenAI GPT-5. Frontier-tier broad-knowledge model. Best for "
|
||||
"open-domain factual recall, creative generation, and "
|
||||
"ambiguous questions where coverage matters. Expensive; use "
|
||||
"for steps where breadth of world knowledge is the bottleneck."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "gpt-5",
|
||||
"endpoint": "openai",
|
||||
"model": "gpt-5",
|
||||
"description": (
|
||||
"OpenAI GPT-5. Frontier-tier broad-knowledge model. Best for "
|
||||
"open-domain factual recall, creative generation, and "
|
||||
"ambiguous questions where coverage matters. Expensive; use "
|
||||
"for steps where breadth of world knowledge is the bottleneck."
|
||||
),
|
||||
}
|
||||
)
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_OPENROUTER"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "deepseek-r1-distill-qwen-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "deepseek/deepseek-r1-distill-qwen-32b",
|
||||
"description": (
|
||||
"DeepSeek R1 distilled into Qwen-32B (open weights via "
|
||||
"OpenRouter). Specialized for chain-of-thought math, logic, "
|
||||
"and competitive-programming-style problems. Verbose; "
|
||||
"produces extensive reasoning traces before the final answer."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "gemma3-27b-it",
|
||||
"endpoint": "openrouter",
|
||||
"model": "google/gemma-3-27b-it",
|
||||
"description": (
|
||||
"Google Gemma 3 27B Instruct (open weights via OpenRouter). "
|
||||
"Mid-size instruction-tuned model. Cheap and fast; solid at "
|
||||
"concise summarization, extraction, and short-form Q&A on "
|
||||
"given context. Weaker than the frontier workers on multi-step "
|
||||
"reasoning."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"description": (
|
||||
"Qwen3-32B in non-thinking mode (open weights via OpenRouter). "
|
||||
"Fast general-purpose dialogue and instruction following. "
|
||||
"Use when the step is straightforward generation, "
|
||||
"summarization, or formatting — does NOT spend tokens on "
|
||||
"internal reasoning."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b-thinking",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"extra_body": {"reasoning": {"effort": "medium"}},
|
||||
"description": (
|
||||
"Qwen3-32B with reasoning enabled (open weights via "
|
||||
"OpenRouter). Same backbone as 'qwen3-32b' but spends tokens "
|
||||
"on an internal chain of thought before answering. Stronger "
|
||||
"on math, code, and multi-step logic; slower and consumes "
|
||||
"more completion tokens. Prefer this for hard reasoning "
|
||||
"steps; prefer the non-thinking variant for plain dialogue."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "deepseek-r1-distill-qwen-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "deepseek/deepseek-r1-distill-qwen-32b",
|
||||
"description": (
|
||||
"DeepSeek R1 distilled into Qwen-32B (open weights via "
|
||||
"OpenRouter). Specialized for chain-of-thought math, logic, "
|
||||
"and competitive-programming-style problems. Verbose; "
|
||||
"produces extensive reasoning traces before the final answer."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "gemma3-27b-it",
|
||||
"endpoint": "openrouter",
|
||||
"model": "google/gemma-3-27b-it",
|
||||
"description": (
|
||||
"Google Gemma 3 27B Instruct (open weights via OpenRouter). "
|
||||
"Mid-size instruction-tuned model. Cheap and fast; solid at "
|
||||
"concise summarization, extraction, and short-form Q&A on "
|
||||
"given context. Weaker than the frontier workers on multi-step "
|
||||
"reasoning."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"description": (
|
||||
"Qwen3-32B in non-thinking mode (open weights via OpenRouter). "
|
||||
"Fast general-purpose dialogue and instruction following. "
|
||||
"Use when the step is straightforward generation, "
|
||||
"summarization, or formatting — does NOT spend tokens on "
|
||||
"internal reasoning."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b-thinking",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"extra_body": {"reasoning": {"effort": "medium"}},
|
||||
"description": (
|
||||
"Qwen3-32B with reasoning enabled (open weights via "
|
||||
"OpenRouter). Same backbone as 'qwen3-32b' but spends tokens "
|
||||
"on an internal chain of thought before answering. Stronger "
|
||||
"on math, code, and multi-step logic; slower and consumes "
|
||||
"more completion tokens. Prefer this for hard reasoning "
|
||||
"steps; prefer the non-thinking variant for plain dialogue."
|
||||
),
|
||||
}
|
||||
)
|
||||
# Reassign ids contiguously in case env-gates skipped some entries.
|
||||
for new_id, entry in enumerate(pool):
|
||||
entry["id"] = new_id
|
||||
@@ -363,16 +377,17 @@ def _resolve_worker_pool(
|
||||
f"Invalid worker_pool entry [{wid_repr}]: 'id' must be an int"
|
||||
)
|
||||
if wid in seen_ids:
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: duplicate id"
|
||||
)
|
||||
raise ValueError(f"Invalid worker_pool entry [{wid}]: duplicate id")
|
||||
seen_ids.add(wid)
|
||||
if not entry.get("name") or not isinstance(entry["name"], str):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'name' must be a non-empty string"
|
||||
)
|
||||
endpoint = entry.get("endpoint") or entry.get("type")
|
||||
if not isinstance(endpoint, str) or endpoint.lower() not in _CONDUCTOR_VALID_ENDPOINTS:
|
||||
if (
|
||||
not isinstance(endpoint, str)
|
||||
or endpoint.lower() not in _CONDUCTOR_VALID_ENDPOINTS
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'endpoint' must be one of "
|
||||
f"{_CONDUCTOR_VALID_ENDPOINTS} (got {endpoint!r})"
|
||||
@@ -466,9 +481,9 @@ def _search_capable_indices(
|
||||
if search_backend == "tavily":
|
||||
return [w["id"] for w in workers]
|
||||
return [
|
||||
w["id"] for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
w["id"]
|
||||
for w in workers
|
||||
if (w.get("endpoint") or "openai").lower() in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
]
|
||||
|
||||
|
||||
@@ -497,7 +512,9 @@ def _build_conductor_prompt(
|
||||
if capable:
|
||||
cap_str = ", ".join(str(i) for i in capable)
|
||||
if search_backend == "tavily":
|
||||
capability = "External Tavily search results will be prepended to worker prompts"
|
||||
capability = (
|
||||
"External Tavily search results will be prepended to worker prompts"
|
||||
)
|
||||
else:
|
||||
capability = "Only these model indices can perform live web search"
|
||||
constraint = (
|
||||
@@ -687,12 +704,18 @@ def _swe_worker_step(
|
||||
ep = (worker.get("endpoint") or "openai").lower()
|
||||
if ep == "vllm":
|
||||
backbone, model, endpoint, is_local = (
|
||||
"local", worker["model"], worker.get("base_url"), True,
|
||||
"local",
|
||||
worker["model"],
|
||||
worker.get("base_url"),
|
||||
True,
|
||||
)
|
||||
cloud_endpoint = "anthropic" # unused on the local path
|
||||
elif ep == "anthropic":
|
||||
backbone, model, endpoint, is_local = (
|
||||
"cloud", worker["model"], None, False,
|
||||
"cloud",
|
||||
worker["model"],
|
||||
None,
|
||||
False,
|
||||
)
|
||||
cloud_endpoint = "anthropic"
|
||||
else:
|
||||
@@ -718,7 +741,11 @@ def _swe_worker_step(
|
||||
)
|
||||
return (
|
||||
out["final_summary"] or out["answer"],
|
||||
out["tokens_in"], out["tokens_out"], is_local, 0, int(out["turns"]),
|
||||
out["tokens_in"],
|
||||
out["tokens_out"],
|
||||
is_local,
|
||||
0,
|
||||
int(out["turns"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -819,21 +846,22 @@ class ConductorAgent(LocalCloudAgent):
|
||||
if plan is None:
|
||||
fallback_used = True
|
||||
plan = {
|
||||
"model_id": [len(workers) - 1],
|
||||
"subtasks": [question],
|
||||
"model_id": [len(workers) - 1],
|
||||
"subtasks": [question],
|
||||
"access_list": [[]],
|
||||
}
|
||||
|
||||
self.record_trace_event({
|
||||
"kind": "conductor_plan",
|
||||
"plan": plan,
|
||||
"fallback_used": fallback_used,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
{k: v for k, v in w.items() if k != "api_key"}
|
||||
for w in workers
|
||||
],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_plan",
|
||||
"plan": plan,
|
||||
"fallback_used": fallback_used,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
{k: v for k, v in w.items() if k != "api_key"} for w in workers
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Execute
|
||||
# If we're on a SWE-bench task AND cfg["swe_use_agent_loop"] is on,
|
||||
@@ -867,14 +895,15 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# constraint — reuse them here.
|
||||
if ws_enabled and search_backend != "tavily" and not swe_mode:
|
||||
search_workers = [
|
||||
w for w in workers
|
||||
w
|
||||
for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
]
|
||||
if not search_workers:
|
||||
endpoints = sorted({
|
||||
(w.get("endpoint") or "openai").lower() for w in workers
|
||||
})
|
||||
endpoints = sorted(
|
||||
{(w.get("endpoint") or "openai").lower() for w in workers}
|
||||
)
|
||||
raise ValueError(
|
||||
f"web_search.enabled=true but the worker pool has no "
|
||||
f"search-capable worker (endpoints present: {endpoints}); "
|
||||
@@ -885,39 +914,43 @@ class ConductorAgent(LocalCloudAgent):
|
||||
)
|
||||
# ``ws_tool`` doubles as the enable marker passed to `_call_worker`
|
||||
# (truthy => route search-capable workers through their agent loop).
|
||||
ws_tool = (
|
||||
build_web_search_tool(ws_max_uses) if ws_enabled else None
|
||||
)
|
||||
ws_tool = build_web_search_tool(ws_max_uses) if ws_enabled else None
|
||||
|
||||
try:
|
||||
if swe_mode:
|
||||
shared_workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"conductor-swe-{task_meta.get('task_id','x')}-"
|
||||
))
|
||||
shared_workdir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"conductor-swe-{task_meta.get('task_id', 'x')}-"
|
||||
)
|
||||
)
|
||||
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
|
||||
self.record_trace_event({
|
||||
"kind": "conductor_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
}
|
||||
)
|
||||
|
||||
for i, (mid, subtask, access) in enumerate(
|
||||
zip(plan["model_id"], plan["subtasks"], plan["access_list"])
|
||||
):
|
||||
worker = workers[mid]
|
||||
prompt = _build_step_prompt(question, subtask, steps, access)
|
||||
self.record_trace_event({
|
||||
"kind": "conductor_step_dispatch",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"prompt": prompt,
|
||||
"swe_mode": swe_mode,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_step_dispatch",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"prompt": prompt,
|
||||
"swe_mode": swe_mode,
|
||||
}
|
||||
)
|
||||
|
||||
worker_ep = (worker.get("endpoint") or "openai").lower()
|
||||
# Post-hoc routing check: if web_search is on but the
|
||||
@@ -926,38 +959,49 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# may legitimately not need search; see Task-3 planner
|
||||
# constraint that tries to prevent this upfront).
|
||||
if (
|
||||
ws_enabled and search_backend != "tavily" and not swe_mode
|
||||
ws_enabled
|
||||
and search_backend != "tavily"
|
||||
and not swe_mode
|
||||
and worker_ep not in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
):
|
||||
self.record_trace_event({
|
||||
"kind": "conductor_search_routing_warning",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_endpoint": worker_ep,
|
||||
"warning": (
|
||||
f"web_search enabled but step {i} routed to "
|
||||
f"search-incapable worker {worker['name']!r} "
|
||||
f"(endpoint {worker_ep!r}); this step cannot "
|
||||
"ground and may answer blind."
|
||||
),
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_search_routing_warning",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_endpoint": worker_ep,
|
||||
"warning": (
|
||||
f"web_search enabled but step {i} routed to "
|
||||
f"search-incapable worker {worker['name']!r} "
|
||||
f"(endpoint {worker_ep!r}); this step cannot "
|
||||
"ground and may answer blind."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
extra_cost = 0.0
|
||||
if swe_mode:
|
||||
text, w_in, w_out, is_local, n_searches, bash_turns = (
|
||||
_swe_worker_step(
|
||||
worker, task_meta, prompt, cfg, shared_workdir, i,
|
||||
worker,
|
||||
task_meta,
|
||||
prompt,
|
||||
cfg,
|
||||
shared_workdir,
|
||||
i,
|
||||
)
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
(
|
||||
text, w_in, w_out, is_local, n_searches, extra_cost
|
||||
) = _call_worker(
|
||||
worker, prompt, cfg,
|
||||
web_search_tool=ws_tool,
|
||||
web_search_max_uses=ws_max_uses,
|
||||
(text, w_in, w_out, is_local, n_searches, extra_cost) = (
|
||||
_call_worker(
|
||||
worker,
|
||||
prompt,
|
||||
cfg,
|
||||
web_search_tool=ws_tool,
|
||||
web_search_max_uses=ws_max_uses,
|
||||
)
|
||||
)
|
||||
|
||||
if is_local:
|
||||
@@ -971,17 +1015,19 @@ class ConductorAgent(LocalCloudAgent):
|
||||
cost += extra_cost
|
||||
n_web_searches_total += n_searches
|
||||
tool_calls += n_searches
|
||||
steps.append({
|
||||
"step_idx": i,
|
||||
"model_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"output": text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
})
|
||||
steps.append(
|
||||
{
|
||||
"step_idx": i,
|
||||
"model_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"output": text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
}
|
||||
)
|
||||
final_answer = text
|
||||
|
||||
# For SWE mode, the authoritative patch is whatever lives in
|
||||
@@ -992,7 +1038,8 @@ class ConductorAgent(LocalCloudAgent):
|
||||
if patch.strip():
|
||||
final_answer = (
|
||||
f"{final_answer}\n\n```diff\n{patch}```"
|
||||
if final_answer else f"```diff\n{patch}```"
|
||||
if final_answer
|
||||
else f"```diff\n{patch}```"
|
||||
)
|
||||
finally:
|
||||
if shared_workdir is not None:
|
||||
@@ -1003,8 +1050,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
tokens_cloud += conductor_p_in + conductor_p_out
|
||||
|
||||
traces = [
|
||||
(s["step_idx"], s["model_id"], s["subtask"], s["output"])
|
||||
for s in steps
|
||||
(s["step_idx"], s["model_id"], s["subtask"], s["output"]) for s in steps
|
||||
]
|
||||
|
||||
meta = {
|
||||
@@ -1023,8 +1069,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
"n_web_searches": n_web_searches_total,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
{k: v for k, v in w.items() if k != "api_key"}
|
||||
for w in workers
|
||||
{k: v for k, v in w.items() if k != "api_key"} for w in workers
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -172,11 +172,16 @@ def _clone_repo(repo: str, base_commit: str, dest: Path) -> None:
|
||||
url = f"https://github.com/{repo}.git"
|
||||
subprocess.run(
|
||||
["git", "clone", "--quiet", url, str(dest)],
|
||||
check=True, timeout=300, capture_output=True,
|
||||
check=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "--quiet", base_commit],
|
||||
cwd=str(dest), check=True, timeout=120, capture_output=True,
|
||||
cwd=str(dest),
|
||||
check=True,
|
||||
timeout=120,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -265,10 +270,14 @@ def _run_bash(
|
||||
stderr = _decode_bash_output(stderr_b, exit_code)
|
||||
truncated = False
|
||||
if len(stdout) > output_cap:
|
||||
stdout = stdout[:output_cap] + f"\n…[+{len(stdout) - output_cap} chars truncated]"
|
||||
stdout = (
|
||||
stdout[:output_cap] + f"\n…[+{len(stdout) - output_cap} chars truncated]"
|
||||
)
|
||||
truncated = True
|
||||
if len(stderr) > output_cap:
|
||||
stderr = stderr[:output_cap] + f"\n…[+{len(stderr) - output_cap} chars truncated]"
|
||||
stderr = (
|
||||
stderr[:output_cap] + f"\n…[+{len(stderr) - output_cap} chars truncated]"
|
||||
)
|
||||
truncated = True
|
||||
return {
|
||||
"stdout": stdout,
|
||||
@@ -297,7 +306,10 @@ def _extract_diff(workdir: Path) -> str:
|
||||
"""``git diff`` against the base commit — the final SWE-bench patch."""
|
||||
proc = subprocess.run(
|
||||
["git", "diff", "--no-color"],
|
||||
cwd=str(workdir), capture_output=True, text=True, timeout=60,
|
||||
cwd=str(workdir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
@@ -320,10 +332,11 @@ def _anthropic_assistant_block(block: Any) -> Dict[str, Any]:
|
||||
|
||||
# ---------- Reusable agent-loop entry point ----------
|
||||
|
||||
|
||||
def run_swe_agent_loop(
|
||||
task: Dict[str, Any],
|
||||
*,
|
||||
backbone: str, # "cloud" or "local"
|
||||
backbone: str, # "cloud" or "local"
|
||||
backbone_model: str,
|
||||
cloud_endpoint: str = "anthropic",
|
||||
local_endpoint: Optional[str] = None,
|
||||
@@ -387,32 +400,33 @@ def run_swe_agent_loop(
|
||||
|
||||
own_workdir = workdir is None
|
||||
if own_workdir:
|
||||
workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"mini-swe-{task.get('task_id','x')}-"
|
||||
))
|
||||
workdir = Path(tempfile.mkdtemp(prefix=f"mini-swe-{task.get('task_id', 'x')}-"))
|
||||
try:
|
||||
_clone_repo(repo, base_commit, workdir)
|
||||
except Exception:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_setup",
|
||||
"repo": repo,
|
||||
"base_commit": base_commit,
|
||||
"workdir": str(workdir),
|
||||
"owns_workdir": own_workdir,
|
||||
"backbone": backbone,
|
||||
"backbone_model": backbone_model,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_setup",
|
||||
"repo": repo,
|
||||
"base_commit": base_commit,
|
||||
"workdir": str(workdir),
|
||||
"owns_workdir": own_workdir,
|
||||
"backbone": backbone,
|
||||
"backbone_model": backbone_model,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
user_prompt = initial_prompt or task.get("problem_statement") or ""
|
||||
|
||||
try:
|
||||
if backbone == "cloud":
|
||||
result = _loop_cloud(
|
||||
user_prompt, workdir,
|
||||
user_prompt,
|
||||
workdir,
|
||||
model=backbone_model,
|
||||
cloud_endpoint=cloud_endpoint,
|
||||
max_turns=max_turns,
|
||||
@@ -423,9 +437,12 @@ def run_swe_agent_loop(
|
||||
)
|
||||
elif backbone == "local":
|
||||
if not local_endpoint:
|
||||
raise ValueError("run_swe_agent_loop(backbone='local') needs local_endpoint")
|
||||
raise ValueError(
|
||||
"run_swe_agent_loop(backbone='local') needs local_endpoint"
|
||||
)
|
||||
result = _loop_local(
|
||||
user_prompt, workdir,
|
||||
user_prompt,
|
||||
workdir,
|
||||
model=backbone_model,
|
||||
endpoint=local_endpoint,
|
||||
max_turns=max_turns,
|
||||
@@ -440,7 +457,7 @@ def run_swe_agent_loop(
|
||||
raise ValueError(f"unsupported backbone: {backbone!r}")
|
||||
|
||||
patch = _extract_diff(workdir)
|
||||
framed = (result["final_summary"] or "[mini-swe-agent produced no summary text]")
|
||||
framed = result["final_summary"] or "[mini-swe-agent produced no summary text]"
|
||||
if patch.strip():
|
||||
framed = f"{framed}\n\n```diff\n{patch}```"
|
||||
|
||||
@@ -450,11 +467,16 @@ def run_swe_agent_loop(
|
||||
"final_summary": result["final_summary"],
|
||||
"tokens_in": result["tokens_in"],
|
||||
"tokens_out": result["tokens_out"],
|
||||
"tokens_local": result["tokens_in"] + result["tokens_out"] if backbone == "local" else 0,
|
||||
"tokens_cloud": result["tokens_in"] + result["tokens_out"] if backbone == "cloud" else 0,
|
||||
"tokens_local": result["tokens_in"] + result["tokens_out"]
|
||||
if backbone == "local"
|
||||
else 0,
|
||||
"tokens_cloud": result["tokens_in"] + result["tokens_out"]
|
||||
if backbone == "cloud"
|
||||
else 0,
|
||||
"cost_usd": (
|
||||
estimate_cost(backbone_model, result["tokens_in"], result["tokens_out"])
|
||||
if backbone == "cloud" else 0.0
|
||||
if backbone == "cloud"
|
||||
else 0.0
|
||||
),
|
||||
"turns": result["turns"],
|
||||
"max_turns_hit": result["max_turns_hit"],
|
||||
@@ -467,6 +489,7 @@ def run_swe_agent_loop(
|
||||
|
||||
# ---------- Cloud loop (dispatcher → per-endpoint multi-turn tool loops) ----------
|
||||
|
||||
|
||||
def _loop_cloud(
|
||||
problem: str,
|
||||
workdir: Path,
|
||||
@@ -485,24 +508,36 @@ def _loop_cloud(
|
||||
to unblock the 8 SWE cells that were stuck on Anthropic-only support."""
|
||||
if cloud_endpoint == "anthropic":
|
||||
return _loop_cloud_anthropic(
|
||||
problem, workdir,
|
||||
model=model, max_turns=max_turns,
|
||||
bash_timeout=bash_timeout, output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
|
||||
problem,
|
||||
workdir,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
bash_timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=trace_prefix,
|
||||
)
|
||||
if cloud_endpoint == "openai":
|
||||
return _loop_cloud_openai(
|
||||
problem, workdir,
|
||||
model=model, max_turns=max_turns,
|
||||
bash_timeout=bash_timeout, output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
|
||||
problem,
|
||||
workdir,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
bash_timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=trace_prefix,
|
||||
)
|
||||
if cloud_endpoint == "gemini":
|
||||
return _loop_cloud_gemini(
|
||||
problem, workdir,
|
||||
model=model, max_turns=max_turns,
|
||||
bash_timeout=bash_timeout, output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
|
||||
problem,
|
||||
workdir,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
bash_timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=trace_prefix,
|
||||
)
|
||||
raise ValueError(
|
||||
f"mini-SWE-agent cloud backbone unsupported endpoint: {cloud_endpoint!r}"
|
||||
@@ -521,6 +556,7 @@ def _loop_cloud_anthropic(
|
||||
trace_prefix: str,
|
||||
) -> Dict[str, Any]:
|
||||
import anthropic
|
||||
|
||||
client = anthropic.Anthropic(timeout=600.0, max_retries=5)
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": problem}]
|
||||
|
||||
@@ -553,30 +589,39 @@ def _loop_cloud_anthropic(
|
||||
btype = getattr(block, "type", None)
|
||||
if btype == "tool_use":
|
||||
tool_uses.append((block.id, block.name, dict(block.input or {})))
|
||||
content_blocks.append({
|
||||
"type": "tool_use", "id": block.id, "name": block.name,
|
||||
"input": dict(block.input or {}),
|
||||
})
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": block.id,
|
||||
"name": block.name,
|
||||
"input": dict(block.input or {}),
|
||||
}
|
||||
)
|
||||
elif hasattr(block, "text"):
|
||||
text_parts.append(block.text)
|
||||
content_blocks.append({"type": "text", "text": block.text})
|
||||
else:
|
||||
content_blocks.append({"type": btype or "unknown"})
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"stop_reason": msg.stop_reason,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"latency_s": latency,
|
||||
"content_blocks": content_blocks,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"stop_reason": msg.stop_reason,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"latency_s": latency,
|
||||
"content_blocks": content_blocks,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
messages.append({"role": "assistant", "content": [
|
||||
_anthropic_assistant_block(b) for b in msg.content
|
||||
]})
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [_anthropic_assistant_block(b) for b in msg.content],
|
||||
}
|
||||
)
|
||||
|
||||
if not tool_uses:
|
||||
final_text = "\n".join(text_parts).strip()
|
||||
@@ -586,28 +631,40 @@ def _loop_cloud_anthropic(
|
||||
for tu_id, tu_name, tu_input in tool_uses:
|
||||
if tu_name != "bash":
|
||||
obs = f"unknown tool: {tu_name!r}"
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn, "name": tu_name, "input": tu_input,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn,
|
||||
"name": tu_name,
|
||||
"input": tu_input,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
command = str(tu_input.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
tool_result_blocks.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu_id,
|
||||
"content": obs,
|
||||
})
|
||||
tool_result_blocks.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu_id,
|
||||
"content": obs,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_result_blocks})
|
||||
|
||||
return {
|
||||
@@ -621,6 +678,7 @@ def _loop_cloud_anthropic(
|
||||
|
||||
# ---------- Cloud loop (OpenAI multi-turn with function tools) ----------
|
||||
|
||||
|
||||
def _loop_cloud_openai(
|
||||
problem: str,
|
||||
workdir: Path,
|
||||
@@ -646,6 +704,7 @@ def _loop_cloud_openai(
|
||||
``_loop_local`` behavior).
|
||||
"""
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(timeout=600.0)
|
||||
|
||||
messages: List[Dict[str, Any]] = [
|
||||
@@ -681,21 +740,27 @@ def _loop_cloud_openai(
|
||||
tool_calls = list(getattr(message, "tool_calls", None) or [])
|
||||
text = message.content or ""
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "openai",
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "openai",
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
# Append the assistant turn (including any tool_calls) so the
|
||||
# follow-up tool messages have the right call ids to reference.
|
||||
@@ -711,7 +776,8 @@ def _loop_cloud_openai(
|
||||
if tool_calls:
|
||||
assistant_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id, "type": "function",
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
@@ -734,21 +800,26 @@ def _loop_cloud_openai(
|
||||
and not text.strip()
|
||||
and turn < max_turns
|
||||
):
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous response was truncated by the token limit "
|
||||
"before producing a tool call or final summary. Retry: "
|
||||
"either issue ONE bash tool call (short command, no large "
|
||||
"output) or send a brief one-line final summary with no "
|
||||
"tool calls to end the loop."
|
||||
),
|
||||
})
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn, "reason": "length_truncation_no_tool_call",
|
||||
"ts": time.time(),
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous response was truncated by the token limit "
|
||||
"before producing a tool call or final summary. Retry: "
|
||||
"either issue ONE bash tool call (short command, no large "
|
||||
"output) or send a brief one-line final summary with no "
|
||||
"tool calls to end the loop."
|
||||
),
|
||||
}
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn,
|
||||
"reason": "length_truncation_no_tool_call",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
# No tool call → the model is done. Same termination rule as
|
||||
# the Anthropic branch.
|
||||
@@ -762,28 +833,40 @@ def _loop_cloud_openai(
|
||||
args = {}
|
||||
if tc.function.name != "bash":
|
||||
obs = f"unknown tool: {tc.function.name!r}"
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn, "name": tc.function.name, "input": args,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn,
|
||||
"name": tc.function.name,
|
||||
"input": args,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
command = str(args.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"tokens_in": tokens_in,
|
||||
@@ -796,6 +879,7 @@ def _loop_cloud_openai(
|
||||
|
||||
# ---------- Cloud loop (Gemini multi-turn with function tools) ----------
|
||||
|
||||
|
||||
def _loop_cloud_gemini(
|
||||
problem: str,
|
||||
workdir: Path,
|
||||
@@ -831,13 +915,15 @@ def _loop_cloud_gemini(
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(http_options=types.HttpOptions(timeout=600_000))
|
||||
bash_tool = types.Tool(function_declarations=[
|
||||
types.FunctionDeclaration(
|
||||
name="bash",
|
||||
description=BASH_TOOL_ANTHROPIC["description"],
|
||||
parameters=BASH_TOOL_GEMINI_PARAMETERS,
|
||||
),
|
||||
])
|
||||
bash_tool = types.Tool(
|
||||
function_declarations=[
|
||||
types.FunctionDeclaration(
|
||||
name="bash",
|
||||
description=BASH_TOOL_ANTHROPIC["description"],
|
||||
parameters=BASH_TOOL_GEMINI_PARAMETERS,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
contents: List[types.Content] = [
|
||||
types.Content(role="user", parts=[types.Part(text=problem)]),
|
||||
@@ -859,7 +945,9 @@ def _loop_cloud_gemini(
|
||||
)
|
||||
t0 = time.time()
|
||||
resp = client.models.generate_content(
|
||||
model=model, contents=contents, config=cfg,
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=cfg,
|
||||
)
|
||||
_bump_cloud_calls()
|
||||
latency = time.time() - t0
|
||||
@@ -896,21 +984,22 @@ def _loop_cloud_gemini(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "gemini",
|
||||
"finish_reason": finish_reason,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"latency_s": latency,
|
||||
"text": "\n".join(text_parts),
|
||||
"tool_calls": [
|
||||
{"name": name, "arguments": args}
|
||||
for name, args in function_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "gemini",
|
||||
"finish_reason": finish_reason,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"latency_s": latency,
|
||||
"text": "\n".join(text_parts),
|
||||
"tool_calls": [
|
||||
{"name": name, "arguments": args} for name, args in function_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
# Append the model's content as-is so the next turn sees its own
|
||||
# prior function_call parts (Gemini requires this for the
|
||||
@@ -934,28 +1023,37 @@ def _loop_cloud_gemini(
|
||||
# treat genuine ``STOP`` with text as a final answer.
|
||||
fr_str = str(finish_reason or "")
|
||||
empty_text = not any(t.strip() for t in text_parts)
|
||||
recoverable = empty_text and turn < max_turns and (
|
||||
"MALFORMED_FUNCTION_CALL" in fr_str
|
||||
or "MAX_TOKENS" in fr_str
|
||||
recoverable = (
|
||||
empty_text
|
||||
and turn < max_turns
|
||||
and ("MALFORMED_FUNCTION_CALL" in fr_str or "MAX_TOKENS" in fr_str)
|
||||
)
|
||||
if recoverable:
|
||||
contents.append(types.Content(
|
||||
role="user",
|
||||
parts=[types.Part(text=(
|
||||
"Your previous response had no parsable function call "
|
||||
"and no final text (finish_reason="
|
||||
f"{fr_str}). Retry: either issue ONE well-formed "
|
||||
"`bash` function call (short command, valid JSON-ish "
|
||||
"args) or send a brief final text message with no "
|
||||
"function call to end the loop."
|
||||
))],
|
||||
))
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn,
|
||||
"reason": f"empty_response_{fr_str}",
|
||||
"ts": time.time(),
|
||||
})
|
||||
contents.append(
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part(
|
||||
text=(
|
||||
"Your previous response had no parsable function call "
|
||||
"and no final text (finish_reason="
|
||||
f"{fr_str}). Retry: either issue ONE well-formed "
|
||||
"`bash` function call (short command, valid JSON-ish "
|
||||
"args) or send a brief final text message with no "
|
||||
"function call to end the loop."
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn,
|
||||
"reason": f"empty_response_{fr_str}",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
final_text = "\n".join(text_parts).strip()
|
||||
break
|
||||
@@ -964,26 +1062,39 @@ def _loop_cloud_gemini(
|
||||
for name, args in function_calls:
|
||||
if name != "bash":
|
||||
obs = f"unknown tool: {name!r}"
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn, "name": name, "input": args,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn,
|
||||
"name": name,
|
||||
"input": args,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
command = str(args.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
response_parts.append(types.Part.from_function_response(
|
||||
name=name, response={"output": obs},
|
||||
))
|
||||
response_parts.append(
|
||||
types.Part.from_function_response(
|
||||
name=name,
|
||||
response={"output": obs},
|
||||
)
|
||||
)
|
||||
contents.append(types.Content(role="user", parts=response_parts))
|
||||
|
||||
return {
|
||||
@@ -1013,10 +1124,14 @@ def _get_tiktoken_enc() -> Any:
|
||||
return _TIKTOKEN_ENC
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
_TIKTOKEN_ENC = tiktoken.get_encoding("cl100k_base")
|
||||
except Exception as exc:
|
||||
if not _TIKTOKEN_WARNED:
|
||||
print(f"[mini_swe_agent] tiktoken unavailable ({exc!r}); falling back to len(s)//4", flush=True)
|
||||
print(
|
||||
f"[mini_swe_agent] tiktoken unavailable ({exc!r}); falling back to len(s)//4",
|
||||
flush=True,
|
||||
)
|
||||
_TIKTOKEN_WARNED = True
|
||||
_TIKTOKEN_ENC = False
|
||||
return _TIKTOKEN_ENC
|
||||
@@ -1038,7 +1153,7 @@ def _estimate_prompt_tokens(messages: List[Dict[str, Any]]) -> int:
|
||||
s = "\n".join(parts)
|
||||
else:
|
||||
s = ""
|
||||
for tc in (m.get("tool_calls") or []):
|
||||
for tc in m.get("tool_calls") or []:
|
||||
try:
|
||||
s += "\n" + (tc["function"]["arguments"] or "")
|
||||
s += "\n" + (tc["function"].get("name") or "")
|
||||
@@ -1119,7 +1234,7 @@ def _compact_local_messages(
|
||||
before_tokens = _estimate_prompt_tokens(messages)
|
||||
new_messages: List[Dict[str, Any]] = list(messages)
|
||||
n_tool_elided = 0
|
||||
for (s, e) in old_turns:
|
||||
for s, e in old_turns:
|
||||
for k in range(s, e):
|
||||
m = new_messages[k]
|
||||
if m.get("role") != "tool":
|
||||
@@ -1140,17 +1255,19 @@ def _compact_local_messages(
|
||||
n_tool_elided += 1
|
||||
|
||||
after_stage1_tokens = _estimate_prompt_tokens(new_messages)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "1",
|
||||
"msgs_before": len(messages),
|
||||
"msgs_after": len(new_messages),
|
||||
"before_tokens": before_tokens,
|
||||
"after_tokens": after_stage1_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": 0,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "1",
|
||||
"msgs_before": len(messages),
|
||||
"msgs_after": len(new_messages),
|
||||
"before_tokens": before_tokens,
|
||||
"after_tokens": after_stage1_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": 0,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
if after_stage1_tokens <= compact_at_tokens:
|
||||
return new_messages
|
||||
@@ -1163,12 +1280,21 @@ def _compact_local_messages(
|
||||
|
||||
summary_input = [
|
||||
{"role": "system", "content": _COMPACT_PROMPT},
|
||||
{"role": "user", "content": json.dumps(
|
||||
[{"role": m.get("role"),
|
||||
"content": m.get("content") if isinstance(m.get("content"), str) else str(m.get("content"))[:4000]}
|
||||
for m in middle],
|
||||
default=str,
|
||||
)[:60_000]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
[
|
||||
{
|
||||
"role": m.get("role"),
|
||||
"content": m.get("content")
|
||||
if isinstance(m.get("content"), str)
|
||||
else str(m.get("content"))[:4000],
|
||||
}
|
||||
for m in middle
|
||||
],
|
||||
default=str,
|
||||
)[:60_000],
|
||||
},
|
||||
]
|
||||
summary = ""
|
||||
try:
|
||||
@@ -1194,18 +1320,20 @@ def _compact_local_messages(
|
||||
}
|
||||
folded = [system_msg, initial_user, synthetic, *tail]
|
||||
after_stage2_tokens = _estimate_prompt_tokens(folded)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "2",
|
||||
"msgs_before": len(new_messages),
|
||||
"msgs_after": len(folded),
|
||||
"before_tokens": after_stage1_tokens,
|
||||
"after_tokens": after_stage2_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": n_turns_folded,
|
||||
"summary_chars": len(summary),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "2",
|
||||
"msgs_before": len(new_messages),
|
||||
"msgs_after": len(folded),
|
||||
"before_tokens": after_stage1_tokens,
|
||||
"after_tokens": after_stage2_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": n_turns_folded,
|
||||
"summary_chars": len(summary),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return folded
|
||||
|
||||
|
||||
@@ -1231,6 +1359,7 @@ def _loop_local(
|
||||
# but still saw 28k-input 400s on the n=100 SWE sweep (the keep window
|
||||
# alone routinely exceeded the budget once bash outputs piled up).
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url=endpoint, api_key="EMPTY", timeout=600.0)
|
||||
|
||||
messages: List[Dict[str, Any]] = [
|
||||
@@ -1243,10 +1372,16 @@ def _loop_local(
|
||||
turns = 0
|
||||
for turn in range(1, max_turns + 1):
|
||||
turns = turn
|
||||
if compact_at_tokens > 0 and _estimate_prompt_tokens(messages) > compact_at_tokens:
|
||||
if (
|
||||
compact_at_tokens > 0
|
||||
and _estimate_prompt_tokens(messages) > compact_at_tokens
|
||||
):
|
||||
messages = _compact_local_messages(
|
||||
messages, client=client, model=model,
|
||||
keep_last=compact_keep_last, trace_prefix=trace_prefix,
|
||||
messages,
|
||||
client=client,
|
||||
model=model,
|
||||
keep_last=compact_keep_last,
|
||||
trace_prefix=trace_prefix,
|
||||
compact_at_tokens=compact_at_tokens,
|
||||
)
|
||||
t0 = time.time()
|
||||
@@ -1268,23 +1403,27 @@ def _loop_local(
|
||||
# server walled the call. Compact aggressively (keep_last=1)
|
||||
# and retry once. Re-raise on anything else or on a second
|
||||
# failure — the runner records the row as errored.
|
||||
from openjarvis.engine._base import looks_like_context_length_error
|
||||
|
||||
msg = str(exc)
|
||||
is_ctx = (
|
||||
"maximum context length" in msg
|
||||
or "context length" in msg.lower() and "exceed" in msg.lower()
|
||||
)
|
||||
is_ctx = looks_like_context_length_error(msg)
|
||||
if not is_ctx:
|
||||
raise
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_emergency_compact",
|
||||
"turn": turn,
|
||||
"error": msg[:300],
|
||||
"tokens_before": _estimate_prompt_tokens(messages),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_emergency_compact",
|
||||
"turn": turn,
|
||||
"error": msg[:300],
|
||||
"tokens_before": _estimate_prompt_tokens(messages),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
messages = _compact_local_messages(
|
||||
messages, client=client, model=model,
|
||||
keep_last=1, trace_prefix=trace_prefix,
|
||||
messages,
|
||||
client=client,
|
||||
model=model,
|
||||
keep_last=1,
|
||||
trace_prefix=trace_prefix,
|
||||
compact_at_tokens=max(8_000, compact_at_tokens // 2),
|
||||
)
|
||||
resp = client.chat.completions.create(
|
||||
@@ -1306,20 +1445,26 @@ def _loop_local(
|
||||
tool_calls = list(getattr(message, "tool_calls", None) or [])
|
||||
text = message.content or ""
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
# Match the OpenAI cloud branch: content="" (not None) when only
|
||||
# tool_calls are present; omit ``tool_calls`` entirely when there
|
||||
@@ -1333,7 +1478,8 @@ def _loop_local(
|
||||
if tool_calls:
|
||||
assistant_local_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id, "type": "function",
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
@@ -1357,20 +1503,28 @@ def _loop_local(
|
||||
else:
|
||||
command = str(args.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"tokens_in": tokens_in,
|
||||
@@ -1383,6 +1537,7 @@ def _loop_local(
|
||||
|
||||
# ---------- Standalone agent ----------
|
||||
|
||||
|
||||
@AgentRegistry.register("mini_swe_agent")
|
||||
class MiniSWEAgent(LocalCloudAgent):
|
||||
"""Single-model bash-loop agent for SWE-bench-shaped tasks.
|
||||
@@ -1410,10 +1565,7 @@ class MiniSWEAgent(LocalCloudAgent):
|
||||
task = context.metadata.get("task") or {}
|
||||
|
||||
backbone = cfg.get("backbone", "cloud")
|
||||
model = (
|
||||
self._cloud_model if backbone == "cloud"
|
||||
else (self._local_model or "")
|
||||
)
|
||||
model = self._cloud_model if backbone == "cloud" else (self._local_model or "")
|
||||
|
||||
out = run_swe_agent_loop(
|
||||
task,
|
||||
|
||||
@@ -87,7 +87,7 @@ MINIONS_FIRST_TURN_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoning": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"required": ["reasoning", "message"],
|
||||
"additionalProperties": False,
|
||||
@@ -104,7 +104,7 @@ MINIONS_CONVERSATION_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": {"const": "request_additional_info"},
|
||||
"message": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"required": ["decision", "message"],
|
||||
"additionalProperties": False,
|
||||
@@ -113,7 +113,7 @@ MINIONS_CONVERSATION_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": {"const": "provide_final_answer"},
|
||||
"answer": {"type": "string"},
|
||||
"answer": {"type": "string"},
|
||||
},
|
||||
"required": ["decision", "answer"],
|
||||
"additionalProperties": False,
|
||||
@@ -126,8 +126,8 @@ MINIONS_CONVERSATION_SCHEMA = {
|
||||
# Markers from Minions's supervisor prompts (prompts/minion.py). Any one
|
||||
# being present in the call's messages/system is a strong Minions signal.
|
||||
MINIONS_PROMPT_MARKERS = (
|
||||
"small language model that has read", # SUPERVISOR_INITIAL_PROMPT
|
||||
"provide_final_answer", # SUPERVISOR_CONVERSATION_PROMPT
|
||||
"small language model that has read", # SUPERVISOR_INITIAL_PROMPT
|
||||
"provide_final_answer", # SUPERVISOR_CONVERSATION_PROMPT
|
||||
"request_additional_info",
|
||||
)
|
||||
|
||||
@@ -171,6 +171,7 @@ def _stub_missing_imports() -> None:
|
||||
"""
|
||||
try:
|
||||
import mistralai
|
||||
|
||||
if not hasattr(mistralai, "Mistral"):
|
||||
mistralai.Mistral = type("Mistral", (), {}) # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
@@ -215,12 +216,12 @@ def _patch_anthropic_globally() -> None:
|
||||
model = kwargs.get("model", "")
|
||||
if model.startswith(NO_TEMP_PREFIXES):
|
||||
kwargs.pop("temperature", None)
|
||||
if (
|
||||
"output_config" not in kwargs
|
||||
and _looks_like_minions_call(kwargs)
|
||||
if "output_config" not in kwargs and _looks_like_minions_call(
|
||||
kwargs
|
||||
):
|
||||
kwargs["output_config"] = _minions_turn_schema(kwargs)
|
||||
return orig(self, **kwargs)
|
||||
|
||||
patched._hybrid_patched = True # type: ignore[attr-defined]
|
||||
return patched
|
||||
|
||||
@@ -358,6 +359,7 @@ def _apply_patches_once() -> None:
|
||||
|
||||
# ---------- Pre-fetch helper (GAIA only) ----------
|
||||
|
||||
|
||||
def _prefetch_context(
|
||||
question: str,
|
||||
cloud_endpoint: str,
|
||||
@@ -376,7 +378,10 @@ def _prefetch_context(
|
||||
and zeros — the protocol still runs.
|
||||
"""
|
||||
out: Dict[str, Any] = {
|
||||
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
|
||||
"text": "",
|
||||
"tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"n_searches": 0,
|
||||
}
|
||||
if search_backend == "tavily":
|
||||
try:
|
||||
@@ -412,10 +417,12 @@ def _prefetch_context(
|
||||
tool_choice={"type": "any"},
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import cost as _cost_usd
|
||||
|
||||
out.update(
|
||||
text=text,
|
||||
tokens=p + c,
|
||||
cost_usd=_cost_usd(cloud_model, p, c) + n_searches * WEB_SEARCH_COST_PER_CALL,
|
||||
cost_usd=_cost_usd(cloud_model, p, c)
|
||||
+ n_searches * WEB_SEARCH_COST_PER_CALL,
|
||||
n_searches=n_searches,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -423,9 +430,7 @@ def _prefetch_context(
|
||||
return out
|
||||
|
||||
|
||||
def _context_for(
|
||||
task: Optional[Dict[str, Any]], prefetched: str = ""
|
||||
) -> List[str]:
|
||||
def _context_for(task: Optional[Dict[str, Any]], prefetched: str = "") -> List[str]:
|
||||
"""Minions wants a context list."""
|
||||
bits: List[str] = []
|
||||
task = task or {}
|
||||
@@ -440,6 +445,7 @@ def _context_for(
|
||||
|
||||
# ---------- Main agent ----------
|
||||
|
||||
|
||||
@AgentRegistry.register("minions")
|
||||
class MinionsAgent(LocalCloudAgent):
|
||||
"""HazyResearch Minions supervisor/worker protocol. See module docstring."""
|
||||
@@ -451,6 +457,7 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# 400/529, KeyError on missing schema fields.
|
||||
try:
|
||||
import anthropic
|
||||
|
||||
if isinstance(exc, anthropic.BadRequestError):
|
||||
return f"{type(exc).__name__}: {str(exc)[:120]}"
|
||||
except Exception:
|
||||
@@ -518,8 +525,7 @@ class MinionsAgent(LocalCloudAgent):
|
||||
local=True,
|
||||
)
|
||||
cloud_max_tokens = int(
|
||||
cfg.get("cloud_max_tokens")
|
||||
or default_max_output_tokens(self._cloud_model)
|
||||
cfg.get("cloud_max_tokens") or default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
if self._cloud_endpoint == "openai":
|
||||
cloud_client = OpenAIClient(
|
||||
@@ -566,9 +572,14 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# - enabled = false → prefetch OFF
|
||||
# - enabled = true → prefetch ON (honors max_uses)
|
||||
prefetch: Dict[str, Any] = {
|
||||
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
|
||||
"text": "",
|
||||
"tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"n_searches": 0,
|
||||
}
|
||||
ws_block = cfg.get("web_search") if isinstance(cfg.get("web_search"), dict) else None
|
||||
ws_block = (
|
||||
cfg.get("web_search") if isinstance(cfg.get("web_search"), dict) else None
|
||||
)
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
# If the cell explicitly set web_search.enabled = false, honor that.
|
||||
# If it set web_search.enabled = true, honor max_uses. If it didn't
|
||||
@@ -588,14 +599,16 @@ class MinionsAgent(LocalCloudAgent):
|
||||
)
|
||||
|
||||
if prefetch.get("text"):
|
||||
self.record_trace_event({
|
||||
"kind": "minions_prefetch",
|
||||
"n_searches": prefetch["n_searches"],
|
||||
"tokens": prefetch["tokens"],
|
||||
"cost_usd": prefetch["cost_usd"],
|
||||
"text": prefetch["text"],
|
||||
"error": prefetch.get("error"),
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "minions_prefetch",
|
||||
"n_searches": prefetch["n_searches"],
|
||||
"tokens": prefetch["tokens"],
|
||||
"cost_usd": prefetch["cost_usd"],
|
||||
"text": prefetch["text"],
|
||||
"error": prefetch.get("error"),
|
||||
}
|
||||
)
|
||||
|
||||
out = protocol(
|
||||
task=input, # full formatted prompt (with bench instruction)
|
||||
@@ -607,15 +620,17 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# The Minions library doesn't go through our SDK helpers, so the
|
||||
# auto-trace missed every turn. Record the protocol output directly —
|
||||
# supervisor_messages + worker_messages contain the full conversation.
|
||||
self.record_trace_event({
|
||||
"kind": "minions_protocol",
|
||||
"mode": mode,
|
||||
"supervisor_messages": out.get("supervisor_messages"),
|
||||
"worker_messages": out.get("worker_messages"),
|
||||
"timing": out.get("timing"),
|
||||
"log_file": out.get("log_file"),
|
||||
"final_answer": out.get("final_answer", ""),
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "minions_protocol",
|
||||
"mode": mode,
|
||||
"supervisor_messages": out.get("supervisor_messages"),
|
||||
"worker_messages": out.get("worker_messages"),
|
||||
"timing": out.get("timing"),
|
||||
"log_file": out.get("log_file"),
|
||||
"final_answer": out.get("final_answer", ""),
|
||||
}
|
||||
)
|
||||
|
||||
local_usage = out.get("local_usage")
|
||||
remote_usage = out.get("remote_usage")
|
||||
@@ -650,7 +665,6 @@ class MinionsAgent(LocalCloudAgent):
|
||||
}
|
||||
return out.get("final_answer", ""), meta
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SWE-bench variant
|
||||
# ------------------------------------------------------------------
|
||||
@@ -668,21 +682,23 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# 1. Cloud supervisor writes a high-level plan (no tools).
|
||||
plan_text, p_in, p_out = self._call_cloud(
|
||||
user=(
|
||||
f"Issue:\n{task.get('problem_statement','')}\n\n"
|
||||
f"Repo: {task.get('repo','')}\n"
|
||||
f"Base commit: {task.get('base_commit','')}\n\n"
|
||||
f"{task.get('hints_text','')}"
|
||||
f"Issue:\n{task.get('problem_statement', '')}\n\n"
|
||||
f"Repo: {task.get('repo', '')}\n"
|
||||
f"Base commit: {task.get('base_commit', '')}\n\n"
|
||||
f"{task.get('hints_text', '')}"
|
||||
),
|
||||
system=MINIONS_SWE_PLANNER_SYS,
|
||||
max_tokens=int(cfg.get("supervisor_max_tokens", 1024)),
|
||||
temperature=0.0,
|
||||
)
|
||||
self.record_trace_event({
|
||||
"kind": "minions_swe_plan",
|
||||
"plan": plan_text,
|
||||
"tokens_in": p_in,
|
||||
"tokens_out": p_out,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "minions_swe_plan",
|
||||
"plan": plan_text,
|
||||
"tokens_in": p_in,
|
||||
"tokens_out": p_out,
|
||||
}
|
||||
)
|
||||
supervisor_cost = self.cost_usd(self._cloud_model, p_in, p_out)
|
||||
|
||||
# 2. Local worker runs mini-SWE-agent with the plan as context.
|
||||
|
||||
@@ -47,33 +47,33 @@ from openjarvis.core.registry import AgentRegistry
|
||||
# would seed before any oracle update.
|
||||
|
||||
SKILL_CATALOG: Dict[str, str] = {
|
||||
"factual_recall": "Recall named entities, dates, places, well-known facts from training data without external lookup.",
|
||||
"factual_recall": "Recall named entities, dates, places, well-known facts from training data without external lookup.",
|
||||
"multi_step_reasoning": "Chain several inference steps together (e.g. compose dates, traverse relationships, decompose then aggregate).",
|
||||
"arithmetic": "Exact numeric computation on values already given in the question.",
|
||||
"web_grounding": "Question needs information likely NOT in a small model's parametric memory (rare facts, recent events, niche sources).",
|
||||
"arithmetic": "Exact numeric computation on values already given in the question.",
|
||||
"web_grounding": "Question needs information likely NOT in a small model's parametric memory (rare facts, recent events, niche sources).",
|
||||
"long_text_extraction": "Read a long supplied document/context and extract a specific piece.",
|
||||
"format_compliance": "Strict output formatting (e.g. GAIA's `FINAL ANSWER: <answer>` rule, comma-separated lists with no units).",
|
||||
"code_or_logic": "Write or trace code, or apply logical/symbolic constraints precisely.",
|
||||
"format_compliance": "Strict output formatting (e.g. GAIA's `FINAL ANSWER: <answer>` rule, comma-separated lists with no units).",
|
||||
"code_or_logic": "Write or trace code, or apply logical/symbolic constraints precisely.",
|
||||
}
|
||||
|
||||
DEFAULT_AGENT_COMPETENCE: Dict[str, Dict[str, float]] = {
|
||||
"local-qwen-27b": {
|
||||
"factual_recall": 0.25,
|
||||
"factual_recall": 0.25,
|
||||
"multi_step_reasoning": 0.30,
|
||||
"arithmetic": 0.55,
|
||||
"web_grounding": 0.10,
|
||||
"arithmetic": 0.55,
|
||||
"web_grounding": 0.10,
|
||||
"long_text_extraction": 0.55,
|
||||
"format_compliance": 0.65,
|
||||
"code_or_logic": 0.45,
|
||||
"format_compliance": 0.65,
|
||||
"code_or_logic": 0.45,
|
||||
},
|
||||
"cloud-opus-4-7": {
|
||||
"factual_recall": 0.85,
|
||||
"factual_recall": 0.85,
|
||||
"multi_step_reasoning": 0.88,
|
||||
"arithmetic": 0.85,
|
||||
"web_grounding": 0.70,
|
||||
"arithmetic": 0.85,
|
||||
"web_grounding": 0.70,
|
||||
"long_text_extraction": 0.90,
|
||||
"format_compliance": 0.92,
|
||||
"code_or_logic": 0.90,
|
||||
"format_compliance": 0.92,
|
||||
"code_or_logic": 0.90,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -189,9 +189,7 @@ def _score_agents(
|
||||
lam = 0.5
|
||||
scores: Dict[str, Dict[str, float]] = {}
|
||||
for aid, comps in competence.items():
|
||||
comp = sum(
|
||||
skill_weights.get(sid, 0.0) * comps[sid] for sid in SKILL_CATALOG
|
||||
)
|
||||
comp = sum(skill_weights.get(sid, 0.0) * comps[sid] for sid in SKILL_CATALOG)
|
||||
cost_pen = lam * cost.get(aid, 0.0)
|
||||
scores[aid] = {
|
||||
"competence": comp,
|
||||
@@ -313,14 +311,16 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
if chosen not in competence:
|
||||
chosen = max(scored, key=lambda a: scored[a]["final_score"])
|
||||
|
||||
self.record_trace_event({
|
||||
"kind": "skillorchestra_route",
|
||||
"chosen_agent": chosen,
|
||||
"skill_weights": skill_weights,
|
||||
"agent_scores": scored,
|
||||
"reasoning": decision.get("reasoning", ""),
|
||||
"router_raw": router_text,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "skillorchestra_route",
|
||||
"chosen_agent": chosen,
|
||||
"skill_weights": skill_weights,
|
||||
"agent_scores": scored,
|
||||
"reasoning": decision.get("reasoning", ""),
|
||||
"router_raw": router_text,
|
||||
}
|
||||
)
|
||||
|
||||
tokens_local = 0
|
||||
tokens_cloud = r_in + r_out
|
||||
|
||||
@@ -41,8 +41,12 @@ from .orchestrator import run_orchestrator
|
||||
from .stage_router import StageSkillHandbook
|
||||
|
||||
_VALID_STRATEGIES = {
|
||||
"none", "router_decides", "analyze_model_decide",
|
||||
"weighted_avg", "weakest_skill", "strongest_skill",
|
||||
"none",
|
||||
"router_decides",
|
||||
"analyze_model_decide",
|
||||
"weighted_avg",
|
||||
"weakest_skill",
|
||||
"strongest_skill",
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +150,11 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
strategy = "none"
|
||||
|
||||
return run_orchestrator(
|
||||
self, input, cfg=cfg, handbook=handbook, strategy=strategy,
|
||||
self,
|
||||
input,
|
||||
cfg=cfg,
|
||||
handbook=handbook,
|
||||
strategy=strategy,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ _STAGE_DEFAULT_ALIAS = {
|
||||
# Orchestrator decision step (raw SDK — needs tool_use blocks back)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _orchestrate_step(
|
||||
agent: Any,
|
||||
*,
|
||||
@@ -126,9 +127,7 @@ def _orchestrate_step(
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(
|
||||
http_options=types.HttpOptions(timeout=600_000)
|
||||
)
|
||||
client = genai.Client(http_options=types.HttpOptions(timeout=600_000))
|
||||
cfg = types.GenerateContentConfig(
|
||||
temperature=1.0,
|
||||
max_output_tokens=max_tokens,
|
||||
@@ -169,16 +168,18 @@ def _orchestrate_step(
|
||||
)
|
||||
|
||||
cost = agent.cost_usd(model, p, c)
|
||||
agent.record_trace_event({
|
||||
"kind": "skillorchestra_orchestrate",
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"prompt": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
})
|
||||
agent.record_trace_event(
|
||||
{
|
||||
"kind": "skillorchestra_orchestrate",
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"prompt": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
}
|
||||
)
|
||||
return text, tool_calls, p, c, cost
|
||||
|
||||
|
||||
@@ -186,6 +187,7 @@ def _orchestrate_step(
|
||||
# Context assembly — eval_frames.py:1305-1351
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_context(
|
||||
doc_list: List[Tuple[str, str]],
|
||||
code_list: List[Tuple[str, str]],
|
||||
@@ -223,6 +225,7 @@ def _build_context(
|
||||
# Main loop — eval_frames.py:run_single
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_orchestrator(
|
||||
agent: Any,
|
||||
problem: str,
|
||||
@@ -245,12 +248,14 @@ def run_orchestrator(
|
||||
# MODEL_NAME). Defaults to the cell's cloud model when that endpoint
|
||||
# supports tool calls, else Opus. ``router_model`` / ``router_endpoint``
|
||||
# are accepted as back-compat aliases (pre-restructure cfg key names).
|
||||
orch_endpoint = (cfg.get("orchestrator_endpoint")
|
||||
or cfg.get("router_endpoint")
|
||||
or agent._cloud_endpoint).lower()
|
||||
orch_model = (cfg.get("orchestrator_model")
|
||||
or cfg.get("router_model")
|
||||
or agent._cloud_model)
|
||||
orch_endpoint = (
|
||||
cfg.get("orchestrator_endpoint")
|
||||
or cfg.get("router_endpoint")
|
||||
or agent._cloud_endpoint
|
||||
).lower()
|
||||
orch_model = (
|
||||
cfg.get("orchestrator_model") or cfg.get("router_model") or agent._cloud_model
|
||||
)
|
||||
if orch_endpoint not in ("anthropic", "openai", "gemini"):
|
||||
orch_endpoint, orch_model = "anthropic", "claude-opus-4-7"
|
||||
orch_max_tokens = int(cfg.get("orchestrator_max_tokens", 4096))
|
||||
@@ -281,7 +286,9 @@ def run_orchestrator(
|
||||
if handbook is not None and strategy != "none":
|
||||
sa = parse_skill_analysis(orch_text)
|
||||
rr = get_routing_strategy(strategy, handbook).select_model(
|
||||
stage, sa, tool_call_model=tool_alias,
|
||||
stage,
|
||||
sa,
|
||||
tool_call_model=tool_alias,
|
||||
)
|
||||
return rr.model_alias
|
||||
return tool_alias or _STAGE_DEFAULT_ALIAS[stage]
|
||||
@@ -290,7 +297,10 @@ def run_orchestrator(
|
||||
used_rounds = step + 1
|
||||
is_last = step == max_rounds - 1
|
||||
context_str = _build_context(
|
||||
doc_list, code_list, attempt_list, char_cap=char_cap,
|
||||
doc_list,
|
||||
code_list,
|
||||
attempt_list,
|
||||
char_cap=char_cap,
|
||||
)
|
||||
|
||||
if handbook is not None and strategy != "none":
|
||||
@@ -301,14 +311,14 @@ def run_orchestrator(
|
||||
handbook=handbook,
|
||||
)
|
||||
else:
|
||||
user = (
|
||||
f"Problem: {problem}\n\n{context_str}\n\n"
|
||||
"Choose an appropriate tool."
|
||||
)
|
||||
user = f"Problem: {problem}\n\n{context_str}\n\nChoose an appropriate tool."
|
||||
|
||||
text, tcalls, p, c, ocost = _orchestrate_step(
|
||||
agent, user=user, model=orch_model,
|
||||
endpoint=orch_endpoint, max_tokens=orch_max_tokens,
|
||||
agent,
|
||||
user=user,
|
||||
model=orch_model,
|
||||
endpoint=orch_endpoint,
|
||||
max_tokens=orch_max_tokens,
|
||||
)
|
||||
tokens_cloud += p + c
|
||||
cost_usd += ocost
|
||||
@@ -337,23 +347,29 @@ def run_orchestrator(
|
||||
tool_alias = (tc.get("input") or {}).get("model")
|
||||
stage = _TOOL_STAGE.get(tool, "answer")
|
||||
chosen_alias = _route(stage, tool_alias, text)
|
||||
spec: ModelSpec = pool.get(chosen_alias) or pool[
|
||||
_STAGE_DEFAULT_ALIAS[stage]
|
||||
]
|
||||
route_log.append({
|
||||
"step": step,
|
||||
"tool": tool,
|
||||
"orchestrator_alias": tool_alias,
|
||||
"routed_alias": chosen_alias,
|
||||
"routed_model": spec.model,
|
||||
"is_local": spec.is_local,
|
||||
})
|
||||
spec: ModelSpec = (
|
||||
pool.get(chosen_alias) or pool[_STAGE_DEFAULT_ALIAS[stage]]
|
||||
)
|
||||
route_log.append(
|
||||
{
|
||||
"step": step,
|
||||
"tool": tool,
|
||||
"orchestrator_alias": tool_alias,
|
||||
"routed_alias": chosen_alias,
|
||||
"routed_model": spec.model,
|
||||
"is_local": spec.is_local,
|
||||
}
|
||||
)
|
||||
tool_calls_n += 1
|
||||
|
||||
if tool == "search":
|
||||
res = run_search(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
retriever_url=retriever_url, web_search_max_uses=ws_max_uses,
|
||||
agent,
|
||||
spec,
|
||||
context_str=context_str,
|
||||
problem=problem,
|
||||
retriever_url=retriever_url,
|
||||
web_search_max_uses=ws_max_uses,
|
||||
search_backend=search_backend,
|
||||
tavily_max_results=tavily_max_results,
|
||||
)
|
||||
@@ -363,13 +379,19 @@ def run_orchestrator(
|
||||
web_uses += res.get("web_search_uses", 0)
|
||||
elif tool in ("enhance_reasoning", "code"):
|
||||
res = run_code(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
agent,
|
||||
spec,
|
||||
context_str=context_str,
|
||||
problem=problem,
|
||||
bash_timeout_s=code_timeout,
|
||||
)
|
||||
code_list.append((res["generated_code"], res["exec_result"]))
|
||||
else: # answer
|
||||
res = run_answer(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
agent,
|
||||
spec,
|
||||
context_str=context_str,
|
||||
problem=problem,
|
||||
max_tokens=answer_max_tokens,
|
||||
)
|
||||
final_pred = res["pred"]
|
||||
@@ -385,12 +407,14 @@ def run_orchestrator(
|
||||
if finish:
|
||||
break
|
||||
|
||||
agent.record_trace_event({
|
||||
"kind": "skillorchestra_route_log",
|
||||
"strategy": strategy,
|
||||
"rounds_used": used_rounds,
|
||||
"routes": route_log,
|
||||
})
|
||||
agent.record_trace_event(
|
||||
{
|
||||
"kind": "skillorchestra_route_log",
|
||||
"strategy": strategy,
|
||||
"rounds_used": used_rounds,
|
||||
"routes": route_log,
|
||||
}
|
||||
)
|
||||
|
||||
meta = {
|
||||
"tokens_local": tokens_local,
|
||||
|
||||
@@ -32,8 +32,14 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
STAGE_ALIASES: Dict[str, List[str]] = {
|
||||
"search": ["search-1", "search-2", "search-3"],
|
||||
"reasoning": ["reasoner-1", "reasoner-2", "reasoner-3"],
|
||||
"answer": ["answer-1", "answer-2", "answer-3", "answer-4",
|
||||
"answer-math-1", "answer-math-2"],
|
||||
"answer": [
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
],
|
||||
}
|
||||
|
||||
# Every alias the orchestrator can emit, flat.
|
||||
@@ -42,9 +48,13 @@ ALL_ALIASES: List[str] = [a for aliases in STAGE_ALIASES.values() for a in alias
|
||||
# Default tier: which aliases collapse onto the cloud model vs the local
|
||||
# model. Dearer ``-1``/``-2`` (+ answer-math-1) -> cloud; cheaper -> local.
|
||||
_CLOUD_ALIASES = {
|
||||
"search-1", "search-2",
|
||||
"reasoner-1", "reasoner-2",
|
||||
"answer-1", "answer-2", "answer-math-1",
|
||||
"search-1",
|
||||
"search-2",
|
||||
"reasoner-1",
|
||||
"reasoner-2",
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-math-1",
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +64,8 @@ class ModelSpec:
|
||||
|
||||
alias: str
|
||||
model: str
|
||||
endpoint: str # "anthropic" | "openai" | "gemini" | "http://..."
|
||||
kind: str # "cloud" | "local"
|
||||
endpoint: str # "anthropic" | "openai" | "gemini" | "http://..."
|
||||
kind: str # "cloud" | "local"
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
@@ -87,7 +97,10 @@ def build_pool(
|
||||
pool[alias] = ModelSpec(alias, cloud_model, cloud_endpoint, "cloud")
|
||||
else:
|
||||
pool[alias] = ModelSpec(
|
||||
alias, local_model, local_endpoint, "local" # type: ignore[arg-type]
|
||||
alias,
|
||||
local_model,
|
||||
local_endpoint,
|
||||
"local", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
for alias, spec in (overrides or {}).items():
|
||||
@@ -133,18 +146,30 @@ def call_alias(
|
||||
ep = spec.endpoint.lower()
|
||||
if ep == "anthropic":
|
||||
text, p, c, _ = agent._call_anthropic(
|
||||
spec.model, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
|
||||
spec.model,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
trace_role="cloud",
|
||||
)
|
||||
elif ep == "openai":
|
||||
text, p, c = agent._call_openai(
|
||||
spec.model, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
|
||||
spec.model,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
trace_role="cloud",
|
||||
)
|
||||
elif ep == "gemini":
|
||||
text, p, c = agent._call_gemini(
|
||||
spec.model, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
|
||||
spec.model,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
trace_role="cloud",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported pool endpoint: {spec.endpoint!r}")
|
||||
|
||||
@@ -94,7 +94,12 @@ class StageSkillHandbook:
|
||||
"answer": {},
|
||||
}
|
||||
self.model_profiles: Dict[str, ModelProfile] = {}
|
||||
self.usage_patterns: Dict[str, Any] = {"stages": {}, "guidelines": {}, "models": {}, "raw": {}}
|
||||
self.usage_patterns: Dict[str, Any] = {
|
||||
"stages": {},
|
||||
"guidelines": {},
|
||||
"models": {},
|
||||
"raw": {},
|
||||
}
|
||||
self.routing_insights: List[str] = []
|
||||
self.learning_history: List[Dict[str, Any]] = []
|
||||
self.version = "1.0.0"
|
||||
@@ -102,7 +107,10 @@ class StageSkillHandbook:
|
||||
self.updated_at = ""
|
||||
|
||||
def get_model_skill_scores(self) -> Dict[str, Dict[str, float]]:
|
||||
return {alias: profile.skill_scores for alias, profile in self.model_profiles.items()}
|
||||
return {
|
||||
alias: profile.skill_scores
|
||||
for alias, profile in self.model_profiles.items()
|
||||
}
|
||||
|
||||
def get_models_for_stage(self, stage: str) -> List[ModelProfile]:
|
||||
return [p for p in self.model_profiles.values() if p.stage == stage]
|
||||
@@ -135,19 +143,29 @@ class StageSkillHandbook:
|
||||
|
||||
def format_model_performance(self, stage: str) -> str:
|
||||
profiles = self.get_models_for_stage(stage)
|
||||
valid_prefixes = {"search": ["search-"], "code": ["reasoner-", "code-"], "answer": ["answer-"]}
|
||||
valid_prefixes = {
|
||||
"search": ["search-"],
|
||||
"code": ["reasoner-", "code-"],
|
||||
"answer": ["answer-"],
|
||||
}
|
||||
prefixes = valid_prefixes.get(stage, [])
|
||||
|
||||
lines = []
|
||||
for p in profiles:
|
||||
if not any(p.model_alias.startswith(prefix) for prefix in prefixes):
|
||||
continue
|
||||
has_data = (p.skill_scores and len(p.skill_scores) > 0) or p.strengths or p.weaknesses
|
||||
has_data = (
|
||||
(p.skill_scores and len(p.skill_scores) > 0)
|
||||
or p.strengths
|
||||
or p.weaknesses
|
||||
)
|
||||
if p.total_attempts > 0 or has_data:
|
||||
lines.append(f"\n### {p.model_alias} ({p.actual_model})")
|
||||
if p.total_attempts > 0:
|
||||
rate = p.total_successes / p.total_attempts
|
||||
lines.append(f"Overall: {rate:.0%} success ({p.total_successes}/{p.total_attempts})")
|
||||
lines.append(
|
||||
f"Overall: {rate:.0%} success ({p.total_successes}/{p.total_attempts})"
|
||||
)
|
||||
else:
|
||||
lines.append("Overall: 0% overall")
|
||||
if p.skill_scores:
|
||||
@@ -157,7 +175,9 @@ class StageSkillHandbook:
|
||||
for sid, s in p.skill_scores.items()
|
||||
if (sid.split(".")[0] if "." in sid else sid) in ("code", stage)
|
||||
}
|
||||
for skill_id, score in sorted(stage_skill_scores.items(), key=lambda x: x[1], reverse=True):
|
||||
for skill_id, score in sorted(
|
||||
stage_skill_scores.items(), key=lambda x: x[1], reverse=True
|
||||
):
|
||||
lines.append(f" - {skill_id}: {score:.0%}")
|
||||
if p.strengths:
|
||||
lines.append(f"Strengths: {', '.join(p.strengths[:3])}")
|
||||
@@ -220,7 +240,9 @@ def parse_skill_analysis(output: str) -> Optional[SkillAnalysis]:
|
||||
try:
|
||||
data = json.loads(match.group(1).strip())
|
||||
required_skills = [
|
||||
SkillWeight(skill_id=s.get("skill_id", ""), percentage=float(s.get("percentage", 0)))
|
||||
SkillWeight(
|
||||
skill_id=s.get("skill_id", ""), percentage=float(s.get("percentage", 0))
|
||||
)
|
||||
for s in data.get("required_skills", [])
|
||||
]
|
||||
return SkillAnalysis(
|
||||
@@ -272,7 +294,14 @@ class RoutingStrategy:
|
||||
if stage == "reasoning":
|
||||
return ["reasoner-1", "reasoner-2", "reasoner-3"]
|
||||
if stage == "answer":
|
||||
return ["answer-1", "answer-2", "answer-3", "answer-4", "answer-math-1", "answer-math-2"]
|
||||
return [
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
]
|
||||
return []
|
||||
|
||||
def select_model(
|
||||
@@ -292,9 +321,17 @@ class RouterDecidesStrategy(RoutingStrategy):
|
||||
tool_call_model: Optional[str] = None,
|
||||
) -> ModelRoutingResult:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "router_decides_from_tool_call", 1.0)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "router_decides_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "router_decides_from_tool_call", 1.0
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "router_decides_fallback", 0.5
|
||||
)
|
||||
|
||||
|
||||
class AnalyzeModelDecideStrategy(RoutingStrategy):
|
||||
@@ -305,17 +342,33 @@ class AnalyzeModelDecideStrategy(RoutingStrategy):
|
||||
tool_call_model: Optional[str] = None,
|
||||
) -> ModelRoutingResult:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "analyze_model_decide_with_skill_analysis", 1.0)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "analyze_model_decide_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "analyze_model_decide_with_skill_analysis", 1.0
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "analyze_model_decide_fallback", 0.5
|
||||
)
|
||||
|
||||
|
||||
class WeightedAverageStrategy(RoutingStrategy):
|
||||
COST_TIERS = {
|
||||
"search-3": 1, "search-2": 2, "search-1": 3,
|
||||
"reasoner-3": 1, "reasoner-2": 2, "reasoner-1": 3,
|
||||
"answer-math-2": 1, "answer-4": 1, "answer-3": 2,
|
||||
"answer-math-1": 2, "answer-2": 3, "answer-1": 4,
|
||||
"search-3": 1,
|
||||
"search-2": 2,
|
||||
"search-1": 3,
|
||||
"reasoner-3": 1,
|
||||
"reasoner-2": 2,
|
||||
"reasoner-1": 3,
|
||||
"answer-math-2": 1,
|
||||
"answer-4": 1,
|
||||
"answer-3": 2,
|
||||
"answer-math-1": 2,
|
||||
"answer-2": 3,
|
||||
"answer-1": 4,
|
||||
}
|
||||
|
||||
def select_model(
|
||||
@@ -326,9 +379,17 @@ class WeightedAverageStrategy(RoutingStrategy):
|
||||
) -> ModelRoutingResult:
|
||||
if not skill_analysis or not skill_analysis.required_skills:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "weighted_avg_no_skills_use_tool_call", 0.7)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weighted_avg_no_skills_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "weighted_avg_no_skills_use_tool_call", 0.7
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weighted_avg_no_skills_fallback", 0.5
|
||||
)
|
||||
|
||||
models = self._get_models_for_stage(stage)
|
||||
model_scores = {}
|
||||
@@ -341,15 +402,25 @@ class WeightedAverageStrategy(RoutingStrategy):
|
||||
score = scores.get(sid, 0.0)
|
||||
weighted_sum += weight * score
|
||||
total_weight += weight
|
||||
model_scores[model] = weighted_sum / total_weight if total_weight > 0 else 0.5
|
||||
model_scores[model] = (
|
||||
weighted_sum / total_weight if total_weight > 0 else 0.5
|
||||
)
|
||||
|
||||
if not model_scores:
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weighted_avg_no_model_scores", 0.5)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weighted_avg_no_model_scores", 0.5
|
||||
)
|
||||
max_score = max(model_scores.values())
|
||||
best = [m for m, s in model_scores.items() if abs(s - max_score) < 0.001]
|
||||
best.sort(key=lambda m: self.COST_TIERS.get(m, 999))
|
||||
return ModelRoutingResult(best[0], "weighted_avg_from_skill_analysis", max_score, model_scores)
|
||||
return ModelRoutingResult(
|
||||
best[0], "weighted_avg_from_skill_analysis", max_score, model_scores
|
||||
)
|
||||
|
||||
|
||||
class WeakestSkillStrategy(RoutingStrategy):
|
||||
@@ -361,18 +432,36 @@ class WeakestSkillStrategy(RoutingStrategy):
|
||||
) -> ModelRoutingResult:
|
||||
if not skill_analysis or not skill_analysis.required_skills:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "weakest_skill_no_skills_use_tool_call", 0.7)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weakest_skill_no_skills_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "weakest_skill_no_skills_use_tool_call", 0.7
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weakest_skill_no_skills_fallback", 0.5
|
||||
)
|
||||
weakest = min(skill_analysis.required_skills, key=lambda s: s.percentage)
|
||||
sid = self._find_skill_id(stage, weakest.skill_id) or weakest.skill_id
|
||||
models = self._get_models_for_stage(stage)
|
||||
model_scores = {m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models}
|
||||
model_scores = {
|
||||
m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models
|
||||
}
|
||||
if not model_scores:
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weakest_skill_no_model_scores", 0.5)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weakest_skill_no_model_scores", 0.5
|
||||
)
|
||||
best = max(model_scores, key=model_scores.get)
|
||||
return ModelRoutingResult(best, f"weakest_skill_{weakest.skill_id}", model_scores[best], model_scores)
|
||||
return ModelRoutingResult(
|
||||
best, f"weakest_skill_{weakest.skill_id}", model_scores[best], model_scores
|
||||
)
|
||||
|
||||
|
||||
class StrongestSkillStrategy(RoutingStrategy):
|
||||
@@ -384,18 +473,41 @@ class StrongestSkillStrategy(RoutingStrategy):
|
||||
) -> ModelRoutingResult:
|
||||
if not skill_analysis or not skill_analysis.required_skills:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "strongest_skill_no_skills_use_tool_call", 0.7)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "strongest_skill_no_skills_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "strongest_skill_no_skills_use_tool_call", 0.7
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"),
|
||||
"strongest_skill_no_skills_fallback",
|
||||
0.5,
|
||||
)
|
||||
strongest = max(skill_analysis.required_skills, key=lambda s: s.percentage)
|
||||
sid = self._find_skill_id(stage, strongest.skill_id) or strongest.skill_id
|
||||
models = self._get_models_for_stage(stage)
|
||||
model_scores = {m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models}
|
||||
model_scores = {
|
||||
m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models
|
||||
}
|
||||
if not model_scores:
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "strongest_skill_no_model_scores", 0.5)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "strongest_skill_no_model_scores", 0.5
|
||||
)
|
||||
best = max(model_scores, key=model_scores.get)
|
||||
return ModelRoutingResult(best, f"strongest_skill_{strongest.skill_id}", model_scores[best], model_scores)
|
||||
return ModelRoutingResult(
|
||||
best,
|
||||
f"strongest_skill_{strongest.skill_id}",
|
||||
model_scores[best],
|
||||
model_scores,
|
||||
)
|
||||
|
||||
|
||||
ROUTING_STRATEGIES = {
|
||||
|
||||
@@ -41,8 +41,7 @@ _SEARCH_CAPABLE_ENDPOINTS = ("anthropic", "openai", "gemini")
|
||||
|
||||
_SEARCH_DESC = "Search for missing information."
|
||||
_CODE_DESC = (
|
||||
"Write and execute Python code to compute intermediate results for "
|
||||
"the problem."
|
||||
"Write and execute Python code to compute intermediate results for the problem."
|
||||
)
|
||||
_ANSWER_DESC = (
|
||||
"Extract the final answer when you have gathered enough information "
|
||||
@@ -52,8 +51,14 @@ _ANSWER_DESC = (
|
||||
_ENUMS = {
|
||||
"search": ["search-1", "search-2", "search-3"],
|
||||
"enhance_reasoning": ["reasoner-1", "reasoner-2", "reasoner-3"],
|
||||
"answer": ["answer-1", "answer-2", "answer-3", "answer-4",
|
||||
"answer-math-1", "answer-math-2"],
|
||||
"answer": [
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -76,15 +81,17 @@ def anthropic_tools() -> List[Dict[str, Any]]:
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -96,18 +103,20 @@ def openai_tools() -> List[Dict[str, Any]]:
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
out.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -119,15 +128,17 @@ def gemini_tools() -> List[Dict[str, Any]]:
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -135,6 +146,7 @@ def gemini_tools() -> List[Dict[str, Any]]:
|
||||
# enhance_reasoning / code — eval_frames.py:659-812
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_code(
|
||||
agent: Any,
|
||||
spec: ModelSpec,
|
||||
@@ -150,7 +162,8 @@ def run_code(
|
||||
rather than raising — the orchestrator learns the model can't code.
|
||||
"""
|
||||
prompt = (
|
||||
context_str.strip() + "\n\n"
|
||||
context_str.strip()
|
||||
+ "\n\n"
|
||||
+ f"Question: {problem}\nInstead of directly answering the question, "
|
||||
"please write additional python code that will give intermidiate "
|
||||
"results after execution. Wrap the code within ```python and ```. "
|
||||
@@ -158,7 +171,11 @@ def run_code(
|
||||
"initialization."
|
||||
)
|
||||
text, p, c, cost = call_alias(
|
||||
agent, spec, user=prompt, max_tokens=8000, temperature=1.0,
|
||||
agent,
|
||||
spec,
|
||||
user=prompt,
|
||||
max_tokens=8000,
|
||||
temperature=1.0,
|
||||
)
|
||||
generated_code = ""
|
||||
if "```python" in text:
|
||||
@@ -197,6 +214,7 @@ def run_code(
|
||||
# answer — eval_frames.py:814-997
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_answer(
|
||||
agent: Any,
|
||||
spec: ModelSpec,
|
||||
@@ -219,11 +237,15 @@ def run_answer(
|
||||
boxed = False
|
||||
|
||||
if "qwen3" in model_l and "235" not in model_l:
|
||||
system = "Please reason step by step, and put your final answer within \\boxed{}."
|
||||
system = (
|
||||
"Please reason step by step, and put your final answer within \\boxed{}."
|
||||
)
|
||||
user = base
|
||||
boxed = True
|
||||
elif "qwen2.5-math" in model_l or "qwen-2.5-math" in model_l:
|
||||
system = "Please reason step by step, and put your final answer within \\boxed{}."
|
||||
system = (
|
||||
"Please reason step by step, and put your final answer within \\boxed{}."
|
||||
)
|
||||
user = base
|
||||
boxed = True
|
||||
else:
|
||||
@@ -236,8 +258,12 @@ def run_answer(
|
||||
)
|
||||
|
||||
text, p, c, cost = call_alias(
|
||||
agent, spec, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=1.0,
|
||||
agent,
|
||||
spec,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=1.0,
|
||||
)
|
||||
|
||||
pred = ""
|
||||
@@ -268,6 +294,7 @@ def run_answer(
|
||||
# search — eval_frames.py:999-1096
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_search(
|
||||
agent: Any,
|
||||
spec: ModelSpec,
|
||||
@@ -288,13 +315,18 @@ def run_search(
|
||||
OpenJarvis substitution for the missing FAISS wiki index).
|
||||
"""
|
||||
prompt = (
|
||||
context_str.strip() + "\n\n"
|
||||
context_str.strip()
|
||||
+ "\n\n"
|
||||
+ f"Question: {problem}\nInstead of directly answering the question, "
|
||||
"please think hard and write a concise query to search Wikipedia. "
|
||||
"Wrap the query within <query> and </query>."
|
||||
)
|
||||
text, p, c, cost = call_alias(
|
||||
agent, spec, user=prompt, max_tokens=8000, temperature=1.0,
|
||||
agent,
|
||||
spec,
|
||||
user=prompt,
|
||||
max_tokens=8000,
|
||||
temperature=1.0,
|
||||
)
|
||||
if "<query>" in text:
|
||||
query = text.split("<query>")[-1].split("</query>")[0].strip()
|
||||
@@ -322,7 +354,9 @@ def run_search(
|
||||
}
|
||||
try:
|
||||
results = requests.post(
|
||||
f"{retriever_url.rstrip('/')}/retrieve", json=payload, timeout=120,
|
||||
f"{retriever_url.rstrip('/')}/retrieve",
|
||||
json=payload,
|
||||
timeout=120,
|
||||
).json()
|
||||
for r in results[0]:
|
||||
doc = r.get("document", {})
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Any, Dict, List, Optional
|
||||
# BetaCompetence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BetaCompetence:
|
||||
"""Bayesian competence estimate for an agent on a specific skill.
|
||||
@@ -80,6 +81,7 @@ class BetaCompetence:
|
||||
# CostStats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostStats:
|
||||
"""Execution cost statistics for an agent under a specific mode.
|
||||
@@ -110,11 +112,17 @@ class CostStats:
|
||||
"""Incremental running-average update."""
|
||||
n = self.total_executions
|
||||
self.avg_prompt_tokens = (self.avg_prompt_tokens * n + prompt_tokens) / (n + 1)
|
||||
self.avg_completion_tokens = (self.avg_completion_tokens * n + completion_tokens) / (n + 1)
|
||||
self.avg_completion_tokens = (
|
||||
self.avg_completion_tokens * n + completion_tokens
|
||||
) / (n + 1)
|
||||
self.avg_latency_s = (self.avg_latency_s * n + latency_s) / (n + 1)
|
||||
self.avg_cost_usd = (self.avg_cost_usd * n + cost_usd) / (n + 1)
|
||||
self.avg_completion_cost_usd = (self.avg_completion_cost_usd * n + completion_cost_usd) / (n + 1)
|
||||
self.avg_prompt_cost_usd = (self.avg_prompt_cost_usd * n + prompt_cost_usd) / (n + 1)
|
||||
self.avg_completion_cost_usd = (
|
||||
self.avg_completion_cost_usd * n + completion_cost_usd
|
||||
) / (n + 1)
|
||||
self.avg_prompt_cost_usd = (self.avg_prompt_cost_usd * n + prompt_cost_usd) / (
|
||||
n + 1
|
||||
)
|
||||
self.total_executions = n + 1
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -137,6 +145,7 @@ class CostStats:
|
||||
# RoutingInsight
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingInsight:
|
||||
"""A single routing insight learned from execution traces"""
|
||||
@@ -165,6 +174,7 @@ class RoutingInsight:
|
||||
# ModeMetadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModeMetadata:
|
||||
"""Mode-level routing metadata."""
|
||||
@@ -197,6 +207,7 @@ class ModeMetadata:
|
||||
# Skill
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillProvenance:
|
||||
"""Tracks how and why a skill was discovered."""
|
||||
@@ -231,7 +242,7 @@ class Skill:
|
||||
indicators: List[str] = field(default_factory=list)
|
||||
examples: List[str] = field(default_factory=list)
|
||||
mode: str = ""
|
||||
parent_skill_id: Optional[str] = None # for hierarchical skills
|
||||
parent_skill_id: Optional[str] = None # for hierarchical skills
|
||||
provenance: SkillProvenance = field(default_factory=SkillProvenance)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -273,6 +284,7 @@ class Skill:
|
||||
# AgentProfile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentProfile:
|
||||
"""Agent profile for skill-aware orchestration."""
|
||||
@@ -309,9 +321,7 @@ class AgentProfile:
|
||||
"""Update competence estimate for a skill."""
|
||||
self.get_competence_dist(skill_id).update(success)
|
||||
|
||||
def weighted_competence(
|
||||
self, skill_weights: Dict[str, float]
|
||||
) -> float:
|
||||
def weighted_competence(self, skill_weights: Dict[str, float]) -> float:
|
||||
"""Compute weighted competence: sum w_{t,sigma} * alpha/(alpha+beta)."""
|
||||
if not skill_weights:
|
||||
return 0.5
|
||||
@@ -334,9 +344,7 @@ class AgentProfile:
|
||||
]
|
||||
return sum(scores) / len(scores) if scores else 0.0
|
||||
|
||||
def category_competence_for_skills(
|
||||
self, active_skill_ids: List[str]
|
||||
) -> float:
|
||||
def category_competence_for_skills(self, active_skill_ids: List[str]) -> float:
|
||||
"""Category-level competence for hierarchical tie-breaking.
|
||||
|
||||
Extracts parent categories from active_skill_ids (e.g. 'entertainment_knowledge'
|
||||
@@ -349,7 +357,9 @@ class AgentProfile:
|
||||
categories.add(cat)
|
||||
if not categories:
|
||||
return 0.0
|
||||
return sum(self.category_competence(cat) for cat in categories) / len(categories)
|
||||
return sum(self.category_competence(cat) for cat in categories) / len(
|
||||
categories
|
||||
)
|
||||
|
||||
@property
|
||||
def overall_success_rate(self) -> float:
|
||||
@@ -390,8 +400,12 @@ class AgentProfile:
|
||||
"skill_scores": skill_scores,
|
||||
"skill_attempts": skill_attempts,
|
||||
"skill_successes": skill_successes,
|
||||
"total_attempts": self.total_attempts if self.total_attempts > 0 else skill_total_attempts,
|
||||
"total_successes": self.total_successes if self.total_attempts > 0 else skill_total_successes,
|
||||
"total_attempts": self.total_attempts
|
||||
if self.total_attempts > 0
|
||||
else skill_total_attempts,
|
||||
"total_successes": self.total_successes
|
||||
if self.total_attempts > 0
|
||||
else skill_total_successes,
|
||||
"cost_stats": self.cost_stats.to_dict(),
|
||||
"routing_signals": self.routing_signals,
|
||||
"strengths": self.strengths,
|
||||
|
||||
@@ -172,8 +172,12 @@ RL_ALL_TOOLS: Dict[str, Dict[str, List[str]]] = {
|
||||
"enhance_reasoning": {"model": ["reasoner-1", "reasoner-2", "reasoner-3"]},
|
||||
"answer": {
|
||||
"model": [
|
||||
"answer-1", "answer-2", "answer-3", "answer-4",
|
||||
"answer-math-1", "answer-math-2",
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
],
|
||||
},
|
||||
"search": {"model": ["search-1", "search-2", "search-3"]},
|
||||
@@ -188,10 +192,14 @@ RL_ALL_TOOLS: Dict[str, Dict[str, List[str]]] = {
|
||||
# so the substitution is deferred until we know the cell's resolved local/cloud
|
||||
# pair. Worker dicts share the schema validated by `_resolve_worker_pool`.
|
||||
|
||||
def _expert_for(slot: str, local_model: Optional[str],
|
||||
local_endpoint: Optional[str],
|
||||
cloud_model: str,
|
||||
cloud_endpoint: str = "anthropic") -> Dict[str, Any]:
|
||||
|
||||
def _expert_for(
|
||||
slot: str,
|
||||
local_model: Optional[str],
|
||||
local_endpoint: Optional[str],
|
||||
cloud_model: str,
|
||||
cloud_endpoint: str = "anthropic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Map an upstream model slot (`answer-1`, `search-3`, …) to a worker spec.
|
||||
|
||||
Routing policy:
|
||||
@@ -357,6 +365,7 @@ def _paper_expert_for(
|
||||
|
||||
# ---- Tavily + Modal helpers -------------------------------------------------
|
||||
|
||||
|
||||
def _call_tavily_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
@@ -390,7 +399,9 @@ def _call_modal_python(code: str, timeout_s: int = 60) -> Tuple[str, int]:
|
||||
# Python image too. We rely on stdlib only — no extra pip installs.
|
||||
image = modal.Image.debian_slim(python_version="3.12")
|
||||
sb = modal.Sandbox.create(
|
||||
"python", "-c", code,
|
||||
"python",
|
||||
"-c",
|
||||
code,
|
||||
app=app,
|
||||
image=image,
|
||||
timeout=int(timeout_s),
|
||||
@@ -485,53 +496,77 @@ def _paper_pool(
|
||||
"""
|
||||
pool: List[Dict[str, Any]] = []
|
||||
if local_model and local_endpoint:
|
||||
pool.append({
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"description": "Local Qwen vLLM (paper uses Qwen3-32B).",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"description": "Local Qwen vLLM (paper uses Qwen3-32B).",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "tavily-search",
|
||||
"type": "tavily-search", "model": "tavily",
|
||||
"description": "Tavily web search.",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "modal-python",
|
||||
"type": "modal-python", "model": "modal-python",
|
||||
"description": "Modal Sandbox for one-shot Python exec.",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "code-specialist",
|
||||
"type": "openrouter", "model": _PAPER_CODER_OPENROUTER,
|
||||
"description": "Qwen-2.5-Coder-32B via OpenRouter (paper).",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "generalist-llama",
|
||||
"type": "openrouter", "model": _PAPER_GENERALIST_TIER3_OPENROUTER,
|
||||
"description": "Llama-3.3-70B-Instruct via OpenRouter (paper tier-3).",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "generalist-gpt5",
|
||||
"type": "openai", "model": "gpt-5",
|
||||
"description": "GPT-5 frontier generalist.",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "generalist-gpt5-mini",
|
||||
"type": "openai", "model": "gpt-5-mini",
|
||||
"description": "GPT-5-mini mid generalist.",
|
||||
})
|
||||
"name": "tavily-search",
|
||||
"type": "tavily-search",
|
||||
"model": "tavily",
|
||||
"description": "Tavily web search.",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "modal-python",
|
||||
"type": "modal-python",
|
||||
"model": "modal-python",
|
||||
"description": "Modal Sandbox for one-shot Python exec.",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "code-specialist",
|
||||
"type": "openrouter",
|
||||
"model": _PAPER_CODER_OPENROUTER,
|
||||
"description": "Qwen-2.5-Coder-32B via OpenRouter (paper).",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "generalist-llama",
|
||||
"type": "openrouter",
|
||||
"model": _PAPER_GENERALIST_TIER3_OPENROUTER,
|
||||
"description": "Llama-3.3-70B-Instruct via OpenRouter (paper tier-3).",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "generalist-gpt5",
|
||||
"type": "openai",
|
||||
"model": "gpt-5",
|
||||
"description": "GPT-5 frontier generalist.",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "generalist-gpt5-mini",
|
||||
"type": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"description": "GPT-5-mini mid generalist.",
|
||||
}
|
||||
)
|
||||
return pool
|
||||
|
||||
|
||||
# Regex for ``<tool_call>{...}</tool_call>`` blocks emitted by Orchestrator-8B
|
||||
# when the vLLM tool parser doesn't catch them (e.g. `qwen3_xml` parser on a
|
||||
# hermes-style template). Captures the JSON payload.
|
||||
_TOOL_CALL_TAG_RE = re.compile(
|
||||
r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL
|
||||
)
|
||||
_TOOL_CALL_TAG_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_rl_tool_call(content: str, sdk_tool_calls: Any) -> Optional[Dict[str, Any]]:
|
||||
@@ -608,7 +643,7 @@ def _strip_fences(s: str) -> str:
|
||||
if s.startswith("```"):
|
||||
first_nl = s.find("\n")
|
||||
if first_nl != -1:
|
||||
s = s[first_nl + 1:]
|
||||
s = s[first_nl + 1 :]
|
||||
if s.endswith("```"):
|
||||
s = s[:-3]
|
||||
s = s.strip()
|
||||
@@ -660,6 +695,7 @@ def _extract_final_answer_text(text: str) -> str:
|
||||
|
||||
# ---------- Worker pool ----------
|
||||
|
||||
|
||||
def _default_pool(
|
||||
local_model: Optional[str],
|
||||
local_endpoint: Optional[str],
|
||||
@@ -677,17 +713,19 @@ def _default_pool(
|
||||
ep = "anthropic"
|
||||
pool: List[Dict[str, Any]] = []
|
||||
if local_model and local_endpoint:
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"description": (
|
||||
"Open-weights Qwen3.5 served locally. Cheap and fast. Good at "
|
||||
"concise extraction, formatting, arithmetic on given data."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"description": (
|
||||
"Open-weights Qwen3.5 served locally. Cheap and fast. Good at "
|
||||
"concise extraction, formatting, arithmetic on given data."
|
||||
),
|
||||
}
|
||||
)
|
||||
if ep == "openai":
|
||||
search_type = "openai-web-search"
|
||||
search_model = cloud_model
|
||||
@@ -700,36 +738,42 @@ def _default_pool(
|
||||
search_type = "anthropic-web-search"
|
||||
search_model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
search_desc = "Anthropic server-side web_search."
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "web-search",
|
||||
"type": search_type,
|
||||
"model": search_model,
|
||||
"description": (
|
||||
f"{search_desc} Use for facts that need a lookup "
|
||||
"(recent events, rare names/dates, niche sources). Returns a digest."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": f"frontier-{ep}",
|
||||
"type": ep,
|
||||
"model": cloud_model,
|
||||
"description": (
|
||||
"Frontier reasoning model. Use for hard multi-step reasoning, "
|
||||
"code review, or a final synthesis pass. Expensive — use sparingly."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "frontier-openai-mini",
|
||||
"type": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"description": (
|
||||
"Mid-tier OpenAI model. Solid general knowledge and reasoning at a "
|
||||
"fraction of frontier cost."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "web-search",
|
||||
"type": search_type,
|
||||
"model": search_model,
|
||||
"description": (
|
||||
f"{search_desc} Use for facts that need a lookup "
|
||||
"(recent events, rare names/dates, niche sources). Returns a digest."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": f"frontier-{ep}",
|
||||
"type": ep,
|
||||
"model": cloud_model,
|
||||
"description": (
|
||||
"Frontier reasoning model. Use for hard multi-step reasoning, "
|
||||
"code review, or a final synthesis pass. Expensive — use sparingly."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "frontier-openai-mini",
|
||||
"type": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"description": (
|
||||
"Mid-tier OpenAI model. Solid general knowledge and reasoning at a "
|
||||
"fraction of frontier cost."
|
||||
),
|
||||
}
|
||||
)
|
||||
return pool
|
||||
|
||||
|
||||
@@ -743,12 +787,21 @@ def _default_pool(
|
||||
# `modal-python` — One-shot Python exec in a fresh Modal Sandbox (the
|
||||
# paper's "Python sandbox" inside `enhance_reasoning`).
|
||||
_TOOLORCH_VALID_TYPES = (
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search",
|
||||
"openai-web-search", "gemini", "gemini-web-search", "tavily-search",
|
||||
"openrouter", "modal-python",
|
||||
"vllm",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"anthropic-web-search",
|
||||
"openai-web-search",
|
||||
"gemini",
|
||||
"gemini-web-search",
|
||||
"tavily-search",
|
||||
"openrouter",
|
||||
"modal-python",
|
||||
)
|
||||
_TOOLORCH_SEARCH_TYPES = (
|
||||
"anthropic-web-search", "openai-web-search", "gemini-web-search",
|
||||
"anthropic-web-search",
|
||||
"openai-web-search",
|
||||
"gemini-web-search",
|
||||
"tavily-search",
|
||||
)
|
||||
|
||||
@@ -808,9 +861,7 @@ def _resolve_worker_pool(
|
||||
f"Invalid worker_pool entry [{wid_repr}]: 'id' must be an int"
|
||||
)
|
||||
if wid in seen_ids:
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: duplicate id"
|
||||
)
|
||||
raise ValueError(f"Invalid worker_pool entry [{wid}]: duplicate id")
|
||||
seen_ids.add(wid)
|
||||
if not entry.get("name") or not isinstance(entry["name"], str):
|
||||
raise ValueError(
|
||||
@@ -850,7 +901,10 @@ def _resolve_worker_pool(
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'model' must be a string when set"
|
||||
)
|
||||
if wtype in ("openai-web-search", "gemini-web-search") and model not in PRICES:
|
||||
if (
|
||||
wtype in ("openai-web-search", "gemini-web-search")
|
||||
and model not in PRICES
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: model {model!r} "
|
||||
f"is not in PRICES (known: {sorted(PRICES)})"
|
||||
@@ -958,7 +1012,9 @@ def _call_worker(
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max(max_tok, 16384) if is_gpt5_family(worker["model"]) else max_tok,
|
||||
max_tokens=max(max_tok, 16384)
|
||||
if is_gpt5_family(worker["model"])
|
||||
else max_tok,
|
||||
temperature=eff_temp,
|
||||
)
|
||||
extra = n_searches * OPENAI_WEB_SEARCH_COST_PER_CALL
|
||||
@@ -975,7 +1031,8 @@ def _call_worker(
|
||||
if wtype == "tavily-search":
|
||||
max_results = int(cfg.get("tavily_max_results", 5))
|
||||
text, p, c, extra, n_searches = _call_tavily_search(
|
||||
str(prompt), max_results=max_results,
|
||||
str(prompt),
|
||||
max_results=max_results,
|
||||
)
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openrouter":
|
||||
@@ -1045,8 +1102,12 @@ def _swe_call_worker(
|
||||
is_local = backbone == "local"
|
||||
return (
|
||||
out["final_summary"] or out["answer"],
|
||||
out["tokens_in"], out["tokens_out"],
|
||||
is_local, 0.0, 0, int(out["turns"]),
|
||||
out["tokens_in"],
|
||||
out["tokens_out"],
|
||||
is_local,
|
||||
0.0,
|
||||
0,
|
||||
int(out["turns"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -1134,20 +1195,24 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
)
|
||||
shared_workdir: Optional[Path] = None
|
||||
if swe_mode:
|
||||
shared_workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"toolorch-swe-{task_meta.get('task_id','x')}-"
|
||||
))
|
||||
shared_workdir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"toolorch-swe-{task_meta.get('task_id', 'x')}-"
|
||||
)
|
||||
)
|
||||
try:
|
||||
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
|
||||
except Exception:
|
||||
shutil.rmtree(shared_workdir, ignore_errors=True)
|
||||
raise
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
}
|
||||
)
|
||||
|
||||
# try/finally guards ``shared_workdir`` against exceptions raised
|
||||
# anywhere in the turn loop, the worker calls, the fallback, or
|
||||
@@ -1185,15 +1250,22 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
cost += self.cost_usd(self._cloud_model, o_in, o_out)
|
||||
|
||||
action = _parse_action(text)
|
||||
history.append({
|
||||
"role": "orchestrator", "turn": turn, "raw": text, "action": action,
|
||||
})
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "orchestrator",
|
||||
"turn": turn,
|
||||
"raw": text,
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
}
|
||||
)
|
||||
|
||||
if action is None:
|
||||
parse_failures += 1
|
||||
@@ -1217,12 +1289,21 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
continue
|
||||
worker = workers[wid]
|
||||
if swe_mode and shared_workdir is not None:
|
||||
(w_text, w_in, w_out, is_local, extra_cost,
|
||||
n_searches, bash_turns) = (
|
||||
_swe_call_worker(
|
||||
worker, str(w_input), cfg, task_meta,
|
||||
shared_workdir, turn,
|
||||
)
|
||||
(
|
||||
w_text,
|
||||
w_in,
|
||||
w_out,
|
||||
is_local,
|
||||
extra_cost,
|
||||
n_searches,
|
||||
bash_turns,
|
||||
) = _swe_call_worker(
|
||||
worker,
|
||||
str(w_input),
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
turn,
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
@@ -1236,17 +1317,19 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
|
||||
n_web_searches_total += n_searches
|
||||
tool_calls += n_searches
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"worker_id": wid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"worker_id": wid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
}
|
||||
)
|
||||
continue
|
||||
# Unknown action kind — treat as parse failure.
|
||||
parse_failures += 1
|
||||
@@ -1258,18 +1341,22 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# Search workers are excluded — they answer fact-lookup
|
||||
# questions, not synthesis.
|
||||
non_search = [
|
||||
w for w in workers
|
||||
if w.get("type") not in _TOOLORCH_SEARCH_TYPES
|
||||
w for w in workers if w.get("type") not in _TOOLORCH_SEARCH_TYPES
|
||||
] or workers
|
||||
worker = max(
|
||||
non_search,
|
||||
key=lambda w: PRICES.get(w.get("model", ""), (0.0, 0.0))[1],
|
||||
)
|
||||
if swe_mode and shared_workdir is not None:
|
||||
(ans, w_in, w_out, is_local, extra_cost, _,
|
||||
bash_turns) = _swe_call_worker(
|
||||
worker, question, cfg, task_meta,
|
||||
shared_workdir, max_turns + 1,
|
||||
(ans, w_in, w_out, is_local, extra_cost, _, bash_turns) = (
|
||||
_swe_call_worker(
|
||||
worker,
|
||||
question,
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
max_turns + 1,
|
||||
)
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
@@ -1281,17 +1368,19 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
else:
|
||||
tokens_cloud += w_in + w_out
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"worker_id": worker["id"],
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"fallback": True,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"worker_id": worker["id"],
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"fallback": True,
|
||||
}
|
||||
)
|
||||
final_answer = ans
|
||||
|
||||
# In SWE mode, the authoritative output is the working-tree diff —
|
||||
@@ -1301,7 +1390,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
if patch.strip():
|
||||
final_answer = (
|
||||
f"{final_answer}\n\n```diff\n{patch}```"
|
||||
if final_answer else f"```diff\n{patch}```"
|
||||
if final_answer
|
||||
else f"```diff\n{patch}```"
|
||||
)
|
||||
|
||||
meta = {
|
||||
@@ -1371,20 +1461,24 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
)
|
||||
shared_workdir: Optional[Path] = None
|
||||
if swe_mode:
|
||||
shared_workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"toolorch-rl-swe-{task_meta.get('task_id','x')}-"
|
||||
))
|
||||
shared_workdir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"toolorch-rl-swe-{task_meta.get('task_id', 'x')}-"
|
||||
)
|
||||
)
|
||||
try:
|
||||
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
|
||||
except Exception:
|
||||
shutil.rmtree(shared_workdir, ignore_errors=True)
|
||||
raise
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_rl_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_rl_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
}
|
||||
)
|
||||
|
||||
# ``context_str`` mirrors the upstream's running context — accumulates
|
||||
# search documents and code/exec snippets across turns. We keep this
|
||||
@@ -1433,40 +1527,53 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
temperature=orch_temp,
|
||||
tools=RL_TOOLS_SPEC,
|
||||
)
|
||||
self.record_trace_event({
|
||||
"kind": "vllm",
|
||||
"role": "orchestrator",
|
||||
"model": orch_model,
|
||||
"endpoint": orch_endpoint,
|
||||
"system": RL_ORCHESTRATOR_SYS,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", None),
|
||||
"function": {
|
||||
"name": getattr(getattr(tc, "function", None), "name", None),
|
||||
"arguments": getattr(getattr(tc, "function", None), "arguments", None),
|
||||
},
|
||||
}
|
||||
for tc in (sdk_tool_calls or [])
|
||||
],
|
||||
"tokens_in": o_in,
|
||||
"tokens_out": o_out,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "vllm",
|
||||
"role": "orchestrator",
|
||||
"model": orch_model,
|
||||
"endpoint": orch_endpoint,
|
||||
"system": RL_ORCHESTRATOR_SYS,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", None),
|
||||
"function": {
|
||||
"name": getattr(
|
||||
getattr(tc, "function", None), "name", None
|
||||
),
|
||||
"arguments": getattr(
|
||||
getattr(tc, "function", None), "arguments", None
|
||||
),
|
||||
},
|
||||
}
|
||||
for tc in (sdk_tool_calls or [])
|
||||
],
|
||||
"tokens_in": o_in,
|
||||
"tokens_out": o_out,
|
||||
}
|
||||
)
|
||||
tokens_local += o_in + o_out
|
||||
|
||||
action = _parse_rl_tool_call(text, sdk_tool_calls)
|
||||
history.append({
|
||||
"role": "orchestrator", "turn": turn, "raw": text, "action": action,
|
||||
})
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_rl_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "orchestrator",
|
||||
"turn": turn,
|
||||
"raw": text,
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_rl_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
}
|
||||
)
|
||||
|
||||
if action is None:
|
||||
parse_failures += 1
|
||||
@@ -1479,8 +1586,10 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
slot = args.get("model", "")
|
||||
|
||||
# Validate against the upstream tool/arg schema.
|
||||
valid = name in RL_ALL_TOOLS and isinstance(slot, str) and (
|
||||
slot in RL_ALL_TOOLS[name]["model"]
|
||||
valid = (
|
||||
name in RL_ALL_TOOLS
|
||||
and isinstance(slot, str)
|
||||
and (slot in RL_ALL_TOOLS[name]["model"])
|
||||
)
|
||||
if not valid:
|
||||
parse_failures += 1
|
||||
@@ -1501,8 +1610,11 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# framing).
|
||||
if paper_mode:
|
||||
worker = _paper_expert_for(
|
||||
slot, self._local_model, self._local_endpoint,
|
||||
self._cloud_model, self._cloud_endpoint,
|
||||
slot,
|
||||
self._local_model,
|
||||
self._local_endpoint,
|
||||
self._cloud_model,
|
||||
self._cloud_endpoint,
|
||||
)
|
||||
# In paper mode, `enhance_reasoning` is always the coder
|
||||
# specialist regardless of the orchestrator's chosen tier.
|
||||
@@ -1516,7 +1628,10 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
}
|
||||
else:
|
||||
worker = _expert_for(
|
||||
slot, self._local_model, self._local_endpoint, self._cloud_model,
|
||||
slot,
|
||||
self._local_model,
|
||||
self._local_endpoint,
|
||||
self._cloud_model,
|
||||
self._cloud_endpoint,
|
||||
)
|
||||
|
||||
@@ -1572,13 +1687,25 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# bash_turns=0; vllm/anthropic-typed workers run the loop.
|
||||
bash_turns = 0
|
||||
if swe_mode and shared_workdir is not None and name != "search":
|
||||
(w_text, w_in, w_out, is_local, extra_cost,
|
||||
n_searches, bash_turns) = _swe_call_worker(
|
||||
worker, w_input, cfg, task_meta, shared_workdir, turn,
|
||||
(
|
||||
w_text,
|
||||
w_in,
|
||||
w_out,
|
||||
is_local,
|
||||
extra_cost,
|
||||
n_searches,
|
||||
bash_turns,
|
||||
) = _swe_call_worker(
|
||||
worker,
|
||||
w_input,
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
turn,
|
||||
)
|
||||
else:
|
||||
w_text, w_in, w_out, is_local, extra_cost, n_searches = _call_worker(
|
||||
worker, w_input, cfg
|
||||
w_text, w_in, w_out, is_local, extra_cost, n_searches = (
|
||||
_call_worker(worker, w_input, cfg)
|
||||
)
|
||||
if is_local:
|
||||
tokens_local += w_in + w_out
|
||||
@@ -1597,13 +1724,13 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# when no python block is found.
|
||||
modal_exec_output: Optional[str] = None
|
||||
modal_exec_rc: Optional[int] = None
|
||||
if (paper_mode and name == "enhance_reasoning"
|
||||
and not swe_mode):
|
||||
if paper_mode and name == "enhance_reasoning" and not swe_mode:
|
||||
code = _extract_first_python_block(w_text)
|
||||
if code:
|
||||
timeout_s = int(cfg.get("modal_python_timeout_s", 60))
|
||||
modal_exec_output, modal_exec_rc = _call_modal_python(
|
||||
code, timeout_s=timeout_s,
|
||||
code,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
tool_calls += 1
|
||||
w_text = (
|
||||
@@ -1611,27 +1738,29 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
f"(rc={modal_exec_rc})]\n{modal_exec_output}"
|
||||
)
|
||||
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"tool": name,
|
||||
"slot": slot,
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
"bash_turns": bash_turns,
|
||||
"modal_exec_rc": modal_exec_rc,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"tool": name,
|
||||
"slot": slot,
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
"bash_turns": bash_turns,
|
||||
"modal_exec_rc": modal_exec_rc,
|
||||
}
|
||||
)
|
||||
|
||||
# Update accumulated context for the next turn.
|
||||
if name == "search":
|
||||
# Treat the search worker's response as a document.
|
||||
doc_list.append(w_text)
|
||||
ctx_docs = "\n\n".join(
|
||||
f"Doc {i+1}: {d}" for i, d in enumerate(doc_list)
|
||||
f"Doc {i + 1}: {d}" for i, d in enumerate(doc_list)
|
||||
)
|
||||
# Crude char-level cap mirrors the upstream's ~24k token cap.
|
||||
context_str = ("Documents:\n" + ctx_docs)[-24000:]
|
||||
@@ -1648,15 +1777,23 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# it can still touch the workdir and emit a diff.
|
||||
expert_fn = _paper_expert_for if paper_mode else _expert_for
|
||||
worker = expert_fn(
|
||||
"answer-1", self._local_model, self._local_endpoint,
|
||||
self._cloud_model, self._cloud_endpoint,
|
||||
"answer-1",
|
||||
self._local_model,
|
||||
self._local_endpoint,
|
||||
self._cloud_model,
|
||||
self._cloud_endpoint,
|
||||
)
|
||||
fb_bash_turns = 0
|
||||
if swe_mode and shared_workdir is not None:
|
||||
(ans, w_in, w_out, is_local, extra_cost,
|
||||
_, fb_bash_turns) = _swe_call_worker(
|
||||
worker, question, cfg, task_meta,
|
||||
shared_workdir, max_turns + 1,
|
||||
(ans, w_in, w_out, is_local, extra_cost, _, fb_bash_turns) = (
|
||||
_swe_call_worker(
|
||||
worker,
|
||||
question,
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
max_turns + 1,
|
||||
)
|
||||
)
|
||||
tool_calls += fb_bash_turns
|
||||
else:
|
||||
@@ -1668,19 +1805,21 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
else:
|
||||
tokens_cloud += w_in + w_out
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"tool": "answer",
|
||||
"slot": "answer-1",
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"bash_turns": fb_bash_turns,
|
||||
"fallback": True,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"tool": "answer",
|
||||
"slot": "answer-1",
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"bash_turns": fb_bash_turns,
|
||||
"fallback": True,
|
||||
}
|
||||
)
|
||||
final_answer = ans
|
||||
|
||||
# In SWE mode, the authoritative output is the working-tree diff —
|
||||
@@ -1690,7 +1829,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
if patch.strip():
|
||||
final_answer = (
|
||||
f"{final_answer}\n\n```diff\n{patch}```"
|
||||
if final_answer else f"```diff\n{patch}```"
|
||||
if final_answer
|
||||
else f"```diff\n{patch}```"
|
||||
)
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -327,9 +327,7 @@ class OpenCodeAgent(BaseAgent):
|
||||
self._ensure_server()
|
||||
except RuntimeError as exc:
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
return AgentResult(
|
||||
content=str(exc), turns=1, metadata={"error": True}
|
||||
)
|
||||
return AgentResult(content=str(exc), turns=1, metadata={"error": True})
|
||||
|
||||
data: dict = {}
|
||||
turn_parts: List[dict] = []
|
||||
|
||||
@@ -57,6 +57,7 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
max_tokens: Optional[int] = None,
|
||||
mode: str = "function_calling",
|
||||
system_prompt: Optional[str] = None,
|
||||
prompt_builder: Optional[Any] = None,
|
||||
parallel_tools: bool = True,
|
||||
interactive: bool = False,
|
||||
confirm_callback=None,
|
||||
@@ -71,6 +72,7 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
max_tokens=max_tokens,
|
||||
interactive=interactive,
|
||||
confirm_callback=confirm_callback,
|
||||
prompt_builder=prompt_builder,
|
||||
)
|
||||
self._mode = mode
|
||||
self._system_prompt = system_prompt
|
||||
@@ -214,7 +216,11 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
self._emit_turn_start(input)
|
||||
|
||||
# Build initial messages
|
||||
messages = self._build_messages(input, context)
|
||||
messages = self._build_messages(
|
||||
input,
|
||||
context,
|
||||
system_prompt=self._system_prompt,
|
||||
)
|
||||
|
||||
# Get OpenAI-format tool definitions
|
||||
openai_tools = self._executor.get_openai_tools() if self._tools else []
|
||||
|
||||
@@ -37,6 +37,7 @@ called from your app startup:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
@@ -56,6 +57,15 @@ from openjarvis.tools.approval_store import (
|
||||
)
|
||||
from openjarvis.tools.proactive_tools import get_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROACTIVE_CRON_PROMPT = (
|
||||
"Run the proactive agent: collect overnight data, execute approved actions, "
|
||||
"notify pending approvals."
|
||||
)
|
||||
_PROACTIVE_TASK_KEY = "proactive-daily"
|
||||
_PROACTIVE_TASK_KEY_FIELD = "openjarvis_task_key"
|
||||
|
||||
_SYSTEM_PROMPT = """You are a proactive personal assistant agent. You have already collected
|
||||
data from the user's connected sources (email, messages, calendar). Your job is to:
|
||||
|
||||
@@ -252,14 +262,31 @@ def _build_notification_channel(channel_spec: str) -> Optional[Any]:
|
||||
|
||||
if ChannelRegistry.contains(channel_type):
|
||||
channel_cls = ChannelRegistry.get(channel_type)
|
||||
instance = channel_cls()
|
||||
# Load credentials from config so the channel uses bot_token from
|
||||
# config.toml rather than falling back to a bare env var.
|
||||
try:
|
||||
instance.connect()
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.system._channel_kwargs import build_channel_kwargs
|
||||
|
||||
_cfg = load_config()
|
||||
_kwargs = build_channel_kwargs(_cfg.channel, channel_type)
|
||||
except Exception:
|
||||
pass
|
||||
_kwargs = {}
|
||||
instance = channel_cls(**_kwargs)
|
||||
# Telegram.send() is self-contained, while connect() starts a
|
||||
# getUpdates loop. A second loop for the same bot token conflicts
|
||||
# with the server's main listener. Other channel implementations
|
||||
# may initialize resources required by send() in connect(), so keep
|
||||
# their established lifecycle intact.
|
||||
if channel_type != "telegram":
|
||||
instance.connect()
|
||||
return instance
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
"Failed to build proactive notification channel %s",
|
||||
channel_type,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -299,6 +326,7 @@ class ProactiveAgent(ToolUsingAgent):
|
||||
self._notification_channel_id
|
||||
)
|
||||
self._notification_channel = notification_channel
|
||||
self._notification_destination = self._notification_channel_id.partition(":")[2]
|
||||
|
||||
from openjarvis.tools.channel_tools import ChannelSendTool
|
||||
from openjarvis.tools.digest_collect import DigestCollectTool
|
||||
@@ -484,13 +512,13 @@ class ProactiveAgent(ToolUsingAgent):
|
||||
# --- Step 5: Build and send notification ---
|
||||
notification = self._build_notification(executed_results, pending_actions)
|
||||
|
||||
if notification and self._notification_channel_id:
|
||||
if notification and self._notification_destination:
|
||||
send_call = ToolCall(
|
||||
id="proactive-notify-1",
|
||||
name="channel_send",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"channel": self._notification_channel_id,
|
||||
"channel": self._notification_destination,
|
||||
"content": notification,
|
||||
}
|
||||
),
|
||||
@@ -592,15 +620,74 @@ def register_cron(
|
||||
hours_back = hours_back or 24
|
||||
timezone = timezone or "America/Los_Angeles"
|
||||
|
||||
metadata = {
|
||||
"notification_channel_id": notification_channel_id,
|
||||
"hours_back": hours_back,
|
||||
"timezone": timezone,
|
||||
_PROACTIVE_TASK_KEY_FIELD: _PROACTIVE_TASK_KEY,
|
||||
}
|
||||
|
||||
# Match the stable key for tasks created by this version and the historical
|
||||
# agent+prompt signature so existing installations are migrated on startup.
|
||||
existing = [
|
||||
task
|
||||
for task in scheduler.list_tasks()
|
||||
if task.status in {"active", "paused"}
|
||||
and task.agent == "proactive"
|
||||
and (
|
||||
task.metadata.get(_PROACTIVE_TASK_KEY_FIELD) == _PROACTIVE_TASK_KEY
|
||||
or (task.prompt == _PROACTIVE_CRON_PROMPT and task.schedule_type == "cron")
|
||||
)
|
||||
]
|
||||
|
||||
# A scheduler pause is an explicit user choice and must survive restart.
|
||||
# Keep one deterministically and remove any active or paused duplicates.
|
||||
paused = [task for task in existing if task.status == "paused"]
|
||||
if paused:
|
||||
keep = min(paused, key=lambda task: task.id)
|
||||
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
|
||||
return keep
|
||||
|
||||
matching = [
|
||||
task
|
||||
for task in existing
|
||||
if task.prompt == _PROACTIVE_CRON_PROMPT
|
||||
and task.schedule_type == "cron"
|
||||
and task.schedule_value == cron_expr
|
||||
and task.context_mode == "isolated"
|
||||
and task.metadata == metadata
|
||||
]
|
||||
if matching:
|
||||
keep = min(matching, key=lambda task: task.id)
|
||||
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
|
||||
return keep
|
||||
|
||||
# Configuration changed. Replace stale active tasks so the schedule and
|
||||
# notification settings from config.toml take effect on this startup.
|
||||
_cancel_proactive_duplicates(scheduler, existing)
|
||||
|
||||
return scheduler.create_task(
|
||||
prompt="Run the proactive agent: collect overnight data, execute approved actions, notify pending approvals.",
|
||||
prompt=_PROACTIVE_CRON_PROMPT,
|
||||
schedule_type="cron",
|
||||
schedule_value=cron_expr,
|
||||
agent="proactive",
|
||||
context_mode="isolated",
|
||||
metadata={
|
||||
"notification_channel_id": notification_channel_id,
|
||||
"hours_back": hours_back,
|
||||
"timezone": timezone,
|
||||
},
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _cancel_proactive_duplicates(
|
||||
scheduler: Any, tasks: List[Any], *, keep: Optional[Any] = None
|
||||
) -> None:
|
||||
"""Cancel managed proactive tasks other than *keep*."""
|
||||
for task in tasks:
|
||||
if keep is not None and task.id == keep.id:
|
||||
continue
|
||||
try:
|
||||
scheduler.cancel_task(task.id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to cancel duplicate proactive task %s",
|
||||
task.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -106,8 +106,8 @@ SEARCH_TOOL_SPEC: Dict[str, Any] = {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Restrict the search to one or more connectors. Use this "
|
||||
"whenever the user names a data source (e.g. \"in my "
|
||||
"Granola notes\" → ['granola']; \"check Slack and Gmail\" "
|
||||
'whenever the user names a data source (e.g. "in my '
|
||||
'Granola notes" → [\'granola\']; "check Slack and Gmail" '
|
||||
"→ ['slack', 'gmail']). Valid IDs include: gmail, slack, "
|
||||
"granola, notion, obsidian, gcalendar, gdrive, gmail_imap, "
|
||||
"outlook, imessage, whatsapp, apple_notes, apple_contacts, "
|
||||
@@ -206,7 +206,9 @@ def shape_results_for_model(
|
||||
if i < detailed_top:
|
||||
base["snippet"] = h.content_snippet
|
||||
if h.thread_context:
|
||||
base["thread"] = _trim_thread_context(h.thread_context, thread_ctx_per_hit)
|
||||
base["thread"] = _trim_thread_context(
|
||||
h.thread_context, thread_ctx_per_hit
|
||||
)
|
||||
out_hits.append(base)
|
||||
return {
|
||||
"num_results": len(hits),
|
||||
@@ -221,7 +223,9 @@ def _hit_date(timestamp: str) -> str:
|
||||
if not timestamp:
|
||||
return ""
|
||||
try:
|
||||
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).date().isoformat()
|
||||
return (
|
||||
datetime.fromisoformat(timestamp.replace("Z", "+00:00")).date().isoformat()
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
return str(timestamp)[:10]
|
||||
|
||||
@@ -239,7 +243,7 @@ def _bare_doc_id(source: str, document_id: str) -> str:
|
||||
return ""
|
||||
prefix = f"{source}:"
|
||||
if source and document_id.startswith(prefix):
|
||||
return document_id[len(prefix):]
|
||||
return document_id[len(prefix) :]
|
||||
return document_id
|
||||
|
||||
|
||||
@@ -520,6 +524,7 @@ class ResearchAgent:
|
||||
def _parse_time_range(raw: Any):
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
def _maybe(v):
|
||||
if not v:
|
||||
return None
|
||||
@@ -527,6 +532,7 @@ class ResearchAgent:
|
||||
return datetime.fromisoformat(str(v).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
start = _maybe(raw.get("start"))
|
||||
end = _maybe(raw.get("end"))
|
||||
if start is None and end is None:
|
||||
@@ -557,9 +563,16 @@ class ResearchAgent:
|
||||
"query": query,
|
||||
"person": person,
|
||||
"time_range": (
|
||||
{"start": time_range[0].isoformat() if time_range and time_range[0] else None,
|
||||
"end": time_range[1].isoformat() if time_range and time_range[1] else None}
|
||||
if time_range else None
|
||||
{
|
||||
"start": time_range[0].isoformat()
|
||||
if time_range and time_range[0]
|
||||
else None,
|
||||
"end": time_range[1].isoformat()
|
||||
if time_range and time_range[1]
|
||||
else None,
|
||||
}
|
||||
if time_range
|
||||
else None
|
||||
),
|
||||
"sources": sources,
|
||||
"limit": limit,
|
||||
@@ -690,9 +703,7 @@ class ResearchAgent:
|
||||
)
|
||||
continue
|
||||
fallback = "(model returned no content and no tool calls)"
|
||||
self._emit(
|
||||
{"type": "final_answer", "text": fallback, "sources": []}
|
||||
)
|
||||
self._emit({"type": "final_answer", "text": fallback, "sources": []})
|
||||
return ResearchResult(
|
||||
answer=fallback,
|
||||
iterations=iterations,
|
||||
@@ -718,7 +729,11 @@ class ResearchAgent:
|
||||
name = tc.get("name", "")
|
||||
raw_args = tc.get("arguments", "{}") or "{}"
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
|
||||
args = (
|
||||
json.loads(raw_args)
|
||||
if isinstance(raw_args, str)
|
||||
else dict(raw_args)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
@@ -766,7 +781,10 @@ class ResearchAgent:
|
||||
)
|
||||
else:
|
||||
self._emit(
|
||||
{"type": "clarify_call", "question": str(args.get("question", ""))}
|
||||
{
|
||||
"type": "clarify_call",
|
||||
"question": str(args.get("question", "")),
|
||||
}
|
||||
)
|
||||
inv = self._execute_clarify(args)
|
||||
invocations.append(inv)
|
||||
@@ -845,9 +863,7 @@ class ResearchAgent:
|
||||
"and the model returned no text response)"
|
||||
)
|
||||
answer, final_sources = _finalize(answer)
|
||||
self._emit(
|
||||
{"type": "final_answer", "text": answer, "sources": final_sources}
|
||||
)
|
||||
self._emit({"type": "final_answer", "text": answer, "sources": final_sources})
|
||||
return ResearchResult(
|
||||
answer=answer,
|
||||
iterations=iterations,
|
||||
|
||||
@@ -123,15 +123,35 @@ class AgentScheduler:
|
||||
self._thread.start()
|
||||
logger.info("Agent scheduler started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the scheduler background thread."""
|
||||
def request_stop(self) -> None:
|
||||
"""Prevent new scheduled ticks without waiting for the worker."""
|
||||
|
||||
self._stop_event.set()
|
||||
if self._bus:
|
||||
self._bus.unsubscribe(EventType.AGENT_TICK_END, self._on_tick_event)
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=10)
|
||||
|
||||
def wait_stopped(self, timeout: float = 10.0) -> bool:
|
||||
"""Wait for an active tick to finish, retaining live thread state."""
|
||||
|
||||
thread = self._thread
|
||||
if thread is None:
|
||||
return True
|
||||
if thread is threading.current_thread():
|
||||
return False
|
||||
thread.join(timeout=timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("Agent scheduler did not stop within %.1fs", timeout)
|
||||
return False
|
||||
if self._thread is thread:
|
||||
self._thread = None
|
||||
logger.info("Agent scheduler stopped")
|
||||
return True
|
||||
|
||||
def stop(self, timeout: float = 10.0) -> None:
|
||||
"""Stop dispatching and wait for the scheduler worker."""
|
||||
|
||||
self.request_stop()
|
||||
if self.wait_stopped(timeout=timeout):
|
||||
logger.info("Agent scheduler stopped")
|
||||
|
||||
def _loop(self) -> None:
|
||||
"""Main scheduler loop."""
|
||||
@@ -160,6 +180,8 @@ class AgentScheduler:
|
||||
]
|
||||
|
||||
for agent_id, info in due:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
agent = self._manager.get_agent(agent_id)
|
||||
if agent is None or agent["status"] in (
|
||||
"paused",
|
||||
|
||||
@@ -13,6 +13,7 @@ class SimpleAgent(BaseAgent):
|
||||
"""Single-turn agent: query -> model -> response. No tool calling."""
|
||||
|
||||
agent_id = "simple"
|
||||
supports_managed_tool_fallback = True
|
||||
|
||||
def run(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
"""Canonical managed-agent tool resolution.
|
||||
|
||||
Managed agents can run through streaming HTTP, immediate/scheduled ticks, or
|
||||
the persistent-agent CLI. Those paths must bind the same live tool instances:
|
||||
agent-type grants first, then configured native tools, then MCP adapters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
import weakref
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BROWSER_SUB_TOOLS = (
|
||||
"browser_navigate",
|
||||
"browser_click",
|
||||
"browser_type",
|
||||
"browser_screenshot",
|
||||
"browser_extract",
|
||||
"browser_axtree",
|
||||
)
|
||||
|
||||
_MEMORY_TOOLS = frozenset(
|
||||
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
|
||||
)
|
||||
_CHANNEL_TOOLS = frozenset({"channel_send", "channel_list", "channel_status"})
|
||||
|
||||
|
||||
class _SpecOverrideTool:
|
||||
"""Delegate execution while exposing an agent-configured OpenAI schema."""
|
||||
|
||||
def __init__(self, wrapped: Any, advertised_spec: dict[str, Any]) -> None:
|
||||
self._wrapped = wrapped
|
||||
self._advertised_spec = advertised_spec
|
||||
|
||||
@property
|
||||
def spec(self) -> Any:
|
||||
base = self._wrapped.spec
|
||||
function = self._advertised_spec.get("function", {})
|
||||
return replace(
|
||||
base,
|
||||
name=function.get("name", base.name),
|
||||
description=function.get("description", base.description),
|
||||
parameters=function.get("parameters", base.parameters),
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> Any:
|
||||
return self._wrapped.execute(**params)
|
||||
|
||||
def to_openai_function(self) -> dict[str, Any]:
|
||||
return self._advertised_spec
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
|
||||
def _tool_name(tool: Any) -> str:
|
||||
try:
|
||||
return str(tool.spec.name)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _spec_name(spec: Mapping[str, Any]) -> str:
|
||||
function = spec.get("function")
|
||||
if not isinstance(function, Mapping):
|
||||
return ""
|
||||
name = function.get("name")
|
||||
return str(name) if name else ""
|
||||
|
||||
|
||||
def _openai_spec(tool: Any) -> dict[str, Any]:
|
||||
to_openai_function = getattr(tool, "to_openai_function", None)
|
||||
if callable(to_openai_function):
|
||||
try:
|
||||
advertised = to_openai_function()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to build advertised schema for tool %r; falling back "
|
||||
"to its ToolSpec",
|
||||
_tool_name(tool),
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
if isinstance(advertised, Mapping) and _spec_name(advertised):
|
||||
return dict(advertised)
|
||||
logger.debug(
|
||||
"Tool %r returned an invalid advertised schema; falling back "
|
||||
"to its ToolSpec",
|
||||
_tool_name(tool),
|
||||
)
|
||||
|
||||
spec = tool.spec
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": spec.parameters,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _close_resources(resources: tuple[Any, ...]) -> None:
|
||||
for resource in reversed(resources):
|
||||
close = getattr(resource, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close resolved tool resource", exc_info=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedAgentTools:
|
||||
"""One resolved toolkit, with views for agent loops and raw streaming."""
|
||||
|
||||
instances: list[Any] = field(default_factory=list)
|
||||
extra_specs: list[dict[str, Any]] = field(default_factory=list)
|
||||
advertised_specs: list[dict[str, Any]] = field(default_factory=list)
|
||||
mcp_clients: list[Any] = field(default_factory=list)
|
||||
owned_resources: list[Any] = field(default_factory=list, repr=False)
|
||||
_closed: bool = field(default=False, init=False, repr=False)
|
||||
_finalizer: weakref.finalize = field(init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# This fallback covers exceptions anywhere after resolution, including
|
||||
# before an executor/response installs its normal explicit cleanup.
|
||||
self._finalizer = weakref.finalize(
|
||||
self,
|
||||
_close_resources,
|
||||
tuple(self.owned_resources),
|
||||
)
|
||||
|
||||
@property
|
||||
def by_name(self) -> dict[str, Any]:
|
||||
return {name: tool for tool in self.instances if (name := _tool_name(tool))}
|
||||
|
||||
@property
|
||||
def openai_specs(self) -> list[dict[str, Any]]:
|
||||
specs: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
advertised = self.advertised_specs
|
||||
if not advertised:
|
||||
advertised = [*map(_openai_spec, self.instances), *self.extra_specs]
|
||||
for spec in advertised:
|
||||
name = _spec_name(spec)
|
||||
if name and name in seen:
|
||||
continue
|
||||
specs.append(spec)
|
||||
if name:
|
||||
seen.add(name)
|
||||
return specs
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close request-local resources without touching shared MCP clients."""
|
||||
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._finalizer()
|
||||
|
||||
def __enter__(self) -> ResolvedAgentTools:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def ensure_registries_populated() -> None:
|
||||
"""Populate tool/channel registries, including after tests clear them."""
|
||||
|
||||
from openjarvis.core.registry import ChannelRegistry, ToolRegistry
|
||||
|
||||
try:
|
||||
import openjarvis.channels # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.tools # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
browser_modules = ("openjarvis.tools.browser", "openjarvis.tools.browser_axtree")
|
||||
for module_name in browser_modules:
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not ChannelRegistry.keys():
|
||||
for module_name in list(sys.modules):
|
||||
if module_name.startswith(
|
||||
"openjarvis.channels."
|
||||
) and not module_name.endswith("_stubs"):
|
||||
try:
|
||||
importlib.reload(sys.modules[module_name])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not ToolRegistry.keys():
|
||||
for module_name in list(sys.modules):
|
||||
if (
|
||||
module_name.startswith("openjarvis.tools.")
|
||||
and not module_name.endswith("_stubs")
|
||||
and not module_name.endswith("agent_tools")
|
||||
):
|
||||
try:
|
||||
importlib.reload(sys.modules[module_name])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not any(ToolRegistry.contains(name) for name in BROWSER_SUB_TOOLS):
|
||||
for module_name in browser_modules:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is not None:
|
||||
try:
|
||||
importlib.reload(module)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def instantiate_registered_tool(
|
||||
tool_cls: Any,
|
||||
name: str,
|
||||
*,
|
||||
engine: Any,
|
||||
model: str,
|
||||
memory_backend: Any = None,
|
||||
channel_backend: Any = None,
|
||||
) -> Any:
|
||||
"""Instantiate a registry tool with its runtime dependencies."""
|
||||
|
||||
if name in _MEMORY_TOOLS:
|
||||
if memory_backend is None:
|
||||
logger.warning(
|
||||
"Memory tool %r instantiated without a backend — calls will "
|
||||
"return no results.",
|
||||
name,
|
||||
)
|
||||
return tool_cls(backend=memory_backend)
|
||||
if name in _CHANNEL_TOOLS:
|
||||
if channel_backend is None:
|
||||
logger.warning(
|
||||
"Channel tool %r instantiated without a channel — calls will "
|
||||
"fail with 'No channel backend configured'.",
|
||||
name,
|
||||
)
|
||||
return tool_cls(channel=channel_backend)
|
||||
if name == "llm":
|
||||
return tool_cls(engine=engine, model=model)
|
||||
return tool_cls()
|
||||
|
||||
|
||||
def build_deep_research_tools(
|
||||
engine: Any,
|
||||
model: str,
|
||||
knowledge_db_path: str | Path | None = None,
|
||||
) -> list[Any]:
|
||||
"""Construct the live knowledge tools granted to ``deep_research``."""
|
||||
|
||||
if not knowledge_db_path:
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
|
||||
knowledge_db_path = DEFAULT_CONFIG_DIR / "knowledge.db"
|
||||
|
||||
path = Path(knowledge_db_path)
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
from openjarvis.connectors.retriever import TwoStageRetriever
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
from openjarvis.tools.scan_chunks import ScanChunksTool
|
||||
from openjarvis.tools.think import ThinkTool
|
||||
|
||||
store = KnowledgeStore(str(path))
|
||||
try:
|
||||
retriever = TwoStageRetriever(store)
|
||||
return [
|
||||
KnowledgeSearchTool(retriever=retriever),
|
||||
KnowledgeSQLTool(store=store),
|
||||
ScanChunksTool(store=store, engine=engine, model=model),
|
||||
ThinkTool(),
|
||||
]
|
||||
except Exception:
|
||||
store.close()
|
||||
raise
|
||||
|
||||
|
||||
def _normalized_tool_config(tool_config: Any) -> list[Any]:
|
||||
if not tool_config:
|
||||
return []
|
||||
if isinstance(tool_config, str):
|
||||
return [part.strip() for part in tool_config.split(",") if part.strip()]
|
||||
if isinstance(tool_config, Mapping):
|
||||
return [dict(tool_config)]
|
||||
try:
|
||||
return list(tool_config)
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
|
||||
def resolve_agent_tools(
|
||||
agent_record: Mapping[str, Any],
|
||||
*,
|
||||
engine: Any,
|
||||
model: str,
|
||||
memory_backend: Any = None,
|
||||
channel_backend: Any = None,
|
||||
mcp_tools: Iterable[Any] = (),
|
||||
mcp_clients: Iterable[Any] = (),
|
||||
knowledge_db_path: str | Path | None = None,
|
||||
) -> ResolvedAgentTools:
|
||||
"""Resolve the effective live toolkit for a managed agent.
|
||||
|
||||
Resolution is stable and first-wins: agent-type grants take precedence
|
||||
over configured registry tools, which take precedence over MCP adapters.
|
||||
``config["mcp_tools"] = false`` excludes MCP adapters from this agent;
|
||||
process-wide runtimes may still own connections used by other agents.
|
||||
"""
|
||||
|
||||
ensure_registries_populated()
|
||||
from openjarvis.core.registry import ChannelRegistry, ToolRegistry
|
||||
|
||||
config = agent_record.get("config") or {}
|
||||
if not isinstance(config, Mapping):
|
||||
config = {}
|
||||
|
||||
instances: list[Any] = []
|
||||
extra_specs: list[dict[str, Any]] = []
|
||||
advertised_specs: list[dict[str, Any]] = []
|
||||
owned_resources: list[Any] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add_instance(
|
||||
tool: Any,
|
||||
*,
|
||||
advertised_spec: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
name = _tool_name(tool)
|
||||
if not name or name in seen:
|
||||
return
|
||||
instances.append(tool)
|
||||
advertised_specs.append(advertised_spec or _openai_spec(tool))
|
||||
seen.add(name)
|
||||
|
||||
use_mcp = config.get("mcp_tools", True) is not False
|
||||
mcp_tool_list = list(mcp_tools) if use_mcp else []
|
||||
mcp_by_name: dict[str, Any] = {}
|
||||
for tool in mcp_tool_list:
|
||||
name = _tool_name(tool)
|
||||
if name and name not in mcp_by_name:
|
||||
mcp_by_name[name] = tool
|
||||
|
||||
if agent_record.get("agent_type") == "deep_research":
|
||||
granted_tools = build_deep_research_tools(
|
||||
engine=engine,
|
||||
model=model,
|
||||
knowledge_db_path=knowledge_db_path,
|
||||
)
|
||||
owned_ids: set[int] = set()
|
||||
for tool in granted_tools:
|
||||
resource = getattr(tool, "_store", None)
|
||||
if (
|
||||
resource is not None
|
||||
and callable(getattr(resource, "close", None))
|
||||
and id(resource) not in owned_ids
|
||||
):
|
||||
owned_resources.append(resource)
|
||||
owned_ids.add(id(resource))
|
||||
add_instance(tool)
|
||||
|
||||
for entry in _normalized_tool_config(config.get("tools")):
|
||||
if isinstance(entry, Mapping):
|
||||
raw_spec = entry if isinstance(entry, dict) else dict(entry)
|
||||
name = _spec_name(raw_spec)
|
||||
if name and name in seen:
|
||||
continue
|
||||
|
||||
backing_tool = None
|
||||
if name and not ChannelRegistry.contains(name):
|
||||
if ToolRegistry.contains(name):
|
||||
try:
|
||||
backing_tool = instantiate_registered_tool(
|
||||
ToolRegistry.get(name),
|
||||
name,
|
||||
engine=engine,
|
||||
model=model,
|
||||
memory_backend=memory_backend,
|
||||
channel_backend=channel_backend,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not instantiate tool '%s' (%s) — "
|
||||
"advertising its custom spec without execution",
|
||||
name,
|
||||
exc,
|
||||
)
|
||||
elif name in mcp_by_name:
|
||||
backing_tool = mcp_by_name[name]
|
||||
|
||||
if backing_tool is not None:
|
||||
add_instance(
|
||||
_SpecOverrideTool(backing_tool, raw_spec),
|
||||
advertised_spec=raw_spec,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Custom tool spec '%s' has no registered or MCP execution "
|
||||
"backend — dropping",
|
||||
name or "<unnamed>",
|
||||
)
|
||||
continue
|
||||
if not isinstance(entry, str):
|
||||
continue
|
||||
|
||||
names = BROWSER_SUB_TOOLS if entry == "browser" else (entry,)
|
||||
for name in names:
|
||||
if name in seen:
|
||||
continue
|
||||
if ChannelRegistry.contains(name):
|
||||
continue
|
||||
if not ToolRegistry.contains(name):
|
||||
logger.warning(
|
||||
"Tool '%s' referenced in agent config but not in ToolRegistry",
|
||||
name,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
add_instance(
|
||||
instantiate_registered_tool(
|
||||
ToolRegistry.get(name),
|
||||
name,
|
||||
engine=engine,
|
||||
model=model,
|
||||
memory_backend=memory_backend,
|
||||
channel_backend=channel_backend,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not instantiate tool '%s' (%s) — dropping", name, exc
|
||||
)
|
||||
|
||||
if use_mcp:
|
||||
for tool in mcp_tool_list:
|
||||
add_instance(tool)
|
||||
|
||||
return ResolvedAgentTools(
|
||||
instances=instances,
|
||||
extra_specs=extra_specs,
|
||||
advertised_specs=advertised_specs,
|
||||
mcp_clients=list(mcp_clients) if use_mcp else [],
|
||||
owned_resources=owned_resources,
|
||||
)
|
||||
|
||||
|
||||
def resolve_tool_specs(tool_config: Any) -> list[dict[str, Any]]:
|
||||
"""Compatibility view for callers that only need configured specs."""
|
||||
|
||||
specs: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for entry in _normalized_tool_config(tool_config):
|
||||
if isinstance(entry, dict):
|
||||
specs.append(entry)
|
||||
name = _spec_name(entry)
|
||||
if name:
|
||||
seen.add(name)
|
||||
continue
|
||||
resolved = resolve_agent_tools(
|
||||
{"config": {"tools": [entry]}},
|
||||
engine=None,
|
||||
model="",
|
||||
)
|
||||
for spec in resolved.openai_specs:
|
||||
name = _spec_name(spec)
|
||||
if name and name in seen:
|
||||
continue
|
||||
specs.append(spec)
|
||||
if name:
|
||||
seen.add(name)
|
||||
return specs
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BROWSER_SUB_TOOLS",
|
||||
"ResolvedAgentTools",
|
||||
"build_deep_research_tools",
|
||||
"ensure_registries_populated",
|
||||
"instantiate_registered_tool",
|
||||
"resolve_agent_tools",
|
||||
"resolve_tool_specs",
|
||||
]
|
||||
@@ -72,15 +72,16 @@ class _OAuth1Auth:
|
||||
all_params[key] = value
|
||||
|
||||
param_str = "&".join(
|
||||
f"{_pct(k)}={_pct(v)}"
|
||||
for k, v in sorted(all_params.items())
|
||||
f"{_pct(k)}={_pct(v)}" for k, v in sorted(all_params.items())
|
||||
)
|
||||
base_string = f"{method}&{_pct(base_url)}&{_pct(param_str)}"
|
||||
|
||||
signing_key = f"{_pct(self._consumer_secret)}&{_pct(self._access_secret)}"
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
signing_key.encode(), base_string.encode(), hashlib.sha1,
|
||||
signing_key.encode(),
|
||||
base_string.encode(),
|
||||
hashlib.sha1,
|
||||
).digest(),
|
||||
).decode()
|
||||
|
||||
@@ -146,7 +147,8 @@ class TwitterChannel(BaseChannel):
|
||||
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
|
||||
self._access_token = access_token or os.environ.get("TWITTER_ACCESS_TOKEN", "")
|
||||
self._access_secret = access_secret or os.environ.get(
|
||||
"TWITTER_ACCESS_SECRET", "",
|
||||
"TWITTER_ACCESS_SECRET",
|
||||
"",
|
||||
)
|
||||
self._bot_user_id = bot_user_id or os.environ.get("TWITTER_BOT_USER_ID", "")
|
||||
self._poll_interval = poll_interval
|
||||
@@ -162,8 +164,10 @@ class TwitterChannel(BaseChannel):
|
||||
|
||||
def _oauth(self) -> _OAuth1Auth:
|
||||
return _OAuth1Auth(
|
||||
self._api_key, self._api_secret,
|
||||
self._access_token, self._access_secret,
|
||||
self._api_key,
|
||||
self._api_secret,
|
||||
self._access_token,
|
||||
self._access_secret,
|
||||
)
|
||||
|
||||
# -- connection lifecycle -----------------------------------------------
|
||||
@@ -179,7 +183,8 @@ class TwitterChannel(BaseChannel):
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._poll_mentions, daemon=True,
|
||||
target=self._poll_mentions,
|
||||
daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
@@ -276,7 +281,10 @@ class TwitterChannel(BaseChannel):
|
||||
params["since_id"] = self._since_id
|
||||
|
||||
resp = httpx.get(
|
||||
url, headers=headers, params=params, timeout=10.0,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
data = resp.json()
|
||||
|
||||
@@ -9,12 +9,12 @@ from __future__ import annotations
|
||||
# readable capital J (the bottom-left \___/ hook), unlike the cramped prior
|
||||
# art where the J read as an I.
|
||||
_WORDMARK = (
|
||||
' ___ _ _ ',
|
||||
' / _ \\ _ __ ___ _ __ | | __ _ _ ____ _(_)___ ',
|
||||
" ___ _ _ ",
|
||||
" / _ \\ _ __ ___ _ __ | | __ _ _ ____ _(_)___ ",
|
||||
"| | | | '_ \\ / _ \\ '_ \\ _ | |/ _` | '__\\ \\ / / / __|",
|
||||
'| |_| | |_) | __/ | | | |_| | (_| | | \\ V /| \\__ \\',
|
||||
' \\___/| .__/ \\___|_| |_|\\___/ \\__,_|_| \\_/ |_|___/',
|
||||
' |_| ',
|
||||
"| |_| | |_) | __/ | | | |_| | (_| | | \\ V /| \\__ \\",
|
||||
" \\___/| .__/ \\___|_| |_|\\___/ \\__,_|_| \\_/ |_|___/",
|
||||
" |_| ",
|
||||
)
|
||||
|
||||
_TAGLINE = "Personal AI, On Personal Devices"
|
||||
|
||||
@@ -20,6 +20,7 @@ from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.engine import (
|
||||
EngineConnectionError,
|
||||
EngineContextLengthError,
|
||||
discover_engines,
|
||||
discover_models,
|
||||
get_engine,
|
||||
@@ -881,6 +882,11 @@ def ask(
|
||||
capability_policy=sec.capability_policy,
|
||||
memory_files_config=effective_mf,
|
||||
)
|
||||
except EngineContextLengthError as exc:
|
||||
# Not a reachability problem — pointing the user at server/host
|
||||
# config (hint_no_engine) would be misleading here.
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
sys.exit(1)
|
||||
except EngineConnectionError as exc:
|
||||
console.print(f"[red]Engine error:[/red] {exc}")
|
||||
console.print(hint_no_engine())
|
||||
@@ -990,6 +996,11 @@ def ask(
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
except EngineContextLengthError as exc:
|
||||
# Not a reachability problem — pointing the user at server/host
|
||||
# config (hint_no_engine) would be misleading here.
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
sys.exit(1)
|
||||
except EngineConnectionError as exc:
|
||||
console.print(f"[red]Engine error:[/red] {exc}")
|
||||
console.print(hint_no_engine())
|
||||
|
||||
@@ -158,7 +158,7 @@ def _show_toml_config(console: Console, config_path: Path) -> None:
|
||||
console.print(f"[dim]Loading config from: {config_path}[/dim]")
|
||||
|
||||
if config_path.exists():
|
||||
config_content = config_path.read_text()
|
||||
config_content = config_path.read_text(encoding="utf-8")
|
||||
syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True)
|
||||
console.print(Panel(syntax, title="Config File", border_style="cyan"))
|
||||
else:
|
||||
@@ -170,7 +170,7 @@ def _show_json_config(console: Console, config_path: Path) -> None:
|
||||
console.print(f"[dim]Loading config from: {config_path}[/dim]")
|
||||
|
||||
if config_path.exists():
|
||||
config_content = config_path.read_text()
|
||||
config_content = config_path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
@@ -375,7 +375,7 @@ def set_config(key: str, value: str) -> None:
|
||||
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
|
||||
)
|
||||
if config_path.exists():
|
||||
doc = tomlkit.parse(config_path.read_text())
|
||||
doc = tomlkit.parse(config_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
doc = tomlkit.document()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -390,7 +390,7 @@ def set_config(key: str, value: str) -> None:
|
||||
current[parts[-1]] = typed_value
|
||||
|
||||
# Write back
|
||||
config_path.write_text(tomlkit.dumps(doc))
|
||||
config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
|
||||
|
||||
console.print(f"[green]Set[/green] {key} = {value!r}")
|
||||
|
||||
|
||||
@@ -81,14 +81,28 @@ def start(
|
||||
if agent_name:
|
||||
cmd.extend(["--agent", agent_name])
|
||||
|
||||
# Start as background process
|
||||
# Start as background process, fully detached from the launching terminal.
|
||||
#
|
||||
# ``start_new_session`` is POSIX-only: CPython's Windows ``_execute_child``
|
||||
# names the parameter ``unused_start_new_session`` and ignores it. Relying
|
||||
# on it there leaves the server sharing its parent's console, so closing
|
||||
# that console — or logging off — delivers CTRL_CLOSE_EVENT and kills the
|
||||
# daemon. DETACHED_PROCESS gives it no console at all; the new process
|
||||
# group additionally stops a Ctrl-C in the parent reaching it.
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_fh = open(_LOG_FILE, "a") # noqa: SIM115
|
||||
spawn_kwargs: dict = {}
|
||||
if sys.platform == "win32":
|
||||
spawn_kwargs["creationflags"] = (
|
||||
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
)
|
||||
else:
|
||||
spawn_kwargs["start_new_session"] = True
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
start_new_session=True,
|
||||
**spawn_kwargs,
|
||||
)
|
||||
_write_pid(proc.pid)
|
||||
|
||||
|
||||
@@ -344,7 +344,9 @@ def init(
|
||||
console.print(f" Looked in: {examples_dir}")
|
||||
raise SystemExit(1)
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DEFAULT_CONFIG_PATH.write_text(preset_path.read_text())
|
||||
DEFAULT_CONFIG_PATH.write_text(
|
||||
preset_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
console.print(
|
||||
f"[green]Preset '{preset}' installed to {DEFAULT_CONFIG_PATH}[/green]"
|
||||
)
|
||||
|
||||
+54
-47
@@ -10,6 +10,7 @@ from rich.console import Console
|
||||
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.credentials import inject_credentials
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.engine import (
|
||||
@@ -122,6 +123,11 @@ def serve(
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Tool credentials saved through the browser UI live in the OpenJarvis
|
||||
# credential store. Restore them before engines and tools are constructed
|
||||
# so availability checks and tool instances see the same environment.
|
||||
inject_credentials()
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Resolve host/port from CLI args or config
|
||||
@@ -273,6 +279,15 @@ def serve(
|
||||
# (which would re-discover the engine, re-resolve tools, re-open the channel,
|
||||
# etc.). See the scheduler block near the bottom of this function (#263).
|
||||
resolved_tools: list = []
|
||||
managed_mcp_tools: list = []
|
||||
mcp_clients: list = []
|
||||
try:
|
||||
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
||||
|
||||
managed_mcp_tools, mcp_clients = load_mcp_tools_from_config(config.tools.mcp)
|
||||
except Exception as exc:
|
||||
logger.warning("Managed-agent MCP tools failed to load: %s", exc)
|
||||
|
||||
if agent_key:
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401
|
||||
@@ -284,11 +299,6 @@ def serve(
|
||||
if sec.capability_policy is not None:
|
||||
agent_kwargs["capability_policy"] = sec.capability_policy
|
||||
|
||||
# MCP transports persisted on the agent at the bottom of
|
||||
# this block — initialise here so the reference is valid
|
||||
# even when accepts_tools is False (#461).
|
||||
mcp_clients: list = []
|
||||
|
||||
# Load tools for agents that support them
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
import openjarvis.tools # noqa: F401 # trigger registration
|
||||
@@ -325,12 +335,13 @@ def serve(
|
||||
|
||||
# MCP server tools from config.tools.mcp.servers
|
||||
# (#461 — these were silently dropped).
|
||||
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
||||
|
||||
mcp_tools, mcp_clients = load_mcp_tools_from_config(
|
||||
config.tools.mcp,
|
||||
allowed_names=allowed if configured else None,
|
||||
)
|
||||
mcp_tools = managed_mcp_tools
|
||||
if configured:
|
||||
mcp_tools = [
|
||||
tool
|
||||
for tool in managed_mcp_tools
|
||||
if tool.spec.name in allowed
|
||||
]
|
||||
if mcp_tools:
|
||||
existing = {t.spec.name for t in tools}
|
||||
for t in mcp_tools:
|
||||
@@ -383,10 +394,6 @@ def serve(
|
||||
channel_agent = config.channel.default_agent or agent_key or "simple"
|
||||
|
||||
_channel_tools: list = []
|
||||
# MCP transports persisted at function scope (= server-process
|
||||
# lifetime); see the comment near the channel-MCP-load block
|
||||
# below. Initialise here so it's always bound. #461.
|
||||
_channel_mcp_clients: list = []
|
||||
if channel_agent:
|
||||
try:
|
||||
import openjarvis.agents
|
||||
@@ -426,29 +433,23 @@ def serve(
|
||||
elif isinstance(_tcls, BaseTool):
|
||||
_channel_tools.append(_tcls)
|
||||
|
||||
# MCP tools for the channel agent too (#461).
|
||||
from openjarvis.mcp.loader import (
|
||||
load_mcp_tools_from_config,
|
||||
)
|
||||
|
||||
_ch_mcp_tools, _ch_mcp_clients = load_mcp_tools_from_config(
|
||||
config.tools.mcp,
|
||||
allowed_names=_allowed if configured else None,
|
||||
)
|
||||
# Reuse the process-owned MCP pool so channels do not
|
||||
# open a second transport to every configured server.
|
||||
_ch_mcp_tools = managed_mcp_tools
|
||||
if configured:
|
||||
_ch_mcp_tools = [
|
||||
tool
|
||||
for tool in managed_mcp_tools
|
||||
if tool.spec.name in _allowed
|
||||
]
|
||||
if _ch_mcp_tools:
|
||||
_existing = {t.spec.name for t in _channel_tools}
|
||||
for t in _ch_mcp_tools:
|
||||
if t.spec.name not in _existing:
|
||||
_channel_tools.append(t)
|
||||
_existing.add(t.spec.name)
|
||||
# Hold a reference at module / function scope —
|
||||
# the channel agent is constructed inside
|
||||
# JarvisSystem below; we extend its lifetime by
|
||||
# keeping the list bound here.
|
||||
_channel_mcp_clients = _ch_mcp_clients
|
||||
except Exception as exc:
|
||||
logger.warning("Channel tools failed to load: %s", exc)
|
||||
_channel_mcp_clients = []
|
||||
|
||||
_wire_system = JarvisSystem(
|
||||
config=config,
|
||||
@@ -458,6 +459,8 @@ def serve(
|
||||
model=model_name,
|
||||
agent_name=channel_agent,
|
||||
tools=_channel_tools,
|
||||
mcp_tools=managed_mcp_tools,
|
||||
_mcp_clients=mcp_clients,
|
||||
)
|
||||
_wire_system.wire_channel(channel_bridge)
|
||||
|
||||
@@ -475,23 +478,24 @@ def serve(
|
||||
# Create app
|
||||
from openjarvis.server.app import create_app
|
||||
|
||||
# Set up memory backend for context injection. Built before the scheduler
|
||||
# block so the executor's JarvisSystem can reference it (#263).
|
||||
# Set up the memory backend for storage tools, API routes, and optional
|
||||
# prompt-context injection. ``context_from_memory`` controls only the last
|
||||
# of those, so disabling it must not leave explicit memory_* tools with a
|
||||
# null backend. Built before the scheduler so AgentExecutor can reuse it.
|
||||
memory_backend = None
|
||||
if config.agent.context_from_memory:
|
||||
try:
|
||||
import openjarvis.tools.storage # noqa: F401
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
try:
|
||||
import openjarvis.tools.storage # noqa: F401
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
|
||||
mem_key = config.memory.default_backend
|
||||
if MemoryRegistry.contains(mem_key):
|
||||
memory_backend = MemoryRegistry.create(
|
||||
mem_key,
|
||||
db_path=config.memory.db_path,
|
||||
)
|
||||
console.print(" Memory: [cyan]active[/cyan]")
|
||||
except Exception as exc:
|
||||
logger.debug("Memory backend init failed: %s", exc)
|
||||
mem_key = config.memory.default_backend
|
||||
if MemoryRegistry.contains(mem_key):
|
||||
memory_backend = MemoryRegistry.create(
|
||||
mem_key,
|
||||
db_path=config.memory.db_path,
|
||||
)
|
||||
console.print(" Memory: [cyan]active[/cyan]")
|
||||
except Exception as exc:
|
||||
logger.debug("Memory backend init failed: %s", exc)
|
||||
|
||||
# Automatic long-term memory service (background fact extraction).
|
||||
memory_service = None
|
||||
@@ -586,6 +590,7 @@ def serve(
|
||||
agent=agent,
|
||||
agent_name=agent_key or "",
|
||||
tools=resolved_tools,
|
||||
mcp_tools=managed_mcp_tools,
|
||||
tool_executor=_sched_tool_executor,
|
||||
memory_backend=memory_backend,
|
||||
telemetry_store=telem_store,
|
||||
@@ -594,6 +599,7 @@ def serve(
|
||||
capability_policy=sec.capability_policy,
|
||||
agent_manager=agent_manager,
|
||||
agent_executor=executor,
|
||||
_mcp_clients=mcp_clients,
|
||||
)
|
||||
executor.set_system(system)
|
||||
|
||||
@@ -685,10 +691,13 @@ def serve(
|
||||
channel_bridge=channel_bridge,
|
||||
config=config,
|
||||
memory_backend=memory_backend,
|
||||
own_memory_backend=memory_backend is not None,
|
||||
memory_service=memory_service,
|
||||
speech_backend=speech_backend,
|
||||
agent_manager=agent_manager,
|
||||
agent_scheduler=agent_scheduler,
|
||||
mcp_tools=managed_mcp_tools,
|
||||
mcp_clients=mcp_clients,
|
||||
api_key=api_key,
|
||||
webhook_config=webhook_config,
|
||||
cors_origins=config.server.cors_origins,
|
||||
@@ -717,6 +726,4 @@ def serve(
|
||||
"authenticated requests to your instance."
|
||||
)
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
||||
|
||||
@@ -199,7 +199,16 @@ def _get_resolver(source: str, url: str = ""):
|
||||
default="",
|
||||
help="Repo URL (required when source is 'github').",
|
||||
)
|
||||
def install(query: str, with_scripts: bool, force: bool, url: str):
|
||||
@click.option(
|
||||
"--yes-dangerous",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Confirm installing an unreviewed skill that requests dangerous "
|
||||
"capabilities (shell/network-listen/filesystem-write)."
|
||||
),
|
||||
)
|
||||
def install(query: str, with_scripts: bool, force: bool, url: str, yes_dangerous: bool):
|
||||
"""Install a skill from a source.
|
||||
|
||||
Example: ``jarvis skill install hermes:apple-notes``
|
||||
@@ -233,7 +242,12 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
|
||||
from openjarvis.skills.tool_translator import ToolTranslator
|
||||
|
||||
importer = SkillImporter(parser=SkillParser(), tool_translator=ToolTranslator())
|
||||
result = importer.import_skill(matches[0], with_scripts=with_scripts, force=force)
|
||||
result = importer.import_skill(
|
||||
matches[0],
|
||||
with_scripts=with_scripts,
|
||||
force=force,
|
||||
confirm_dangerous=yes_dangerous,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
if result.skipped:
|
||||
@@ -270,6 +284,15 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
|
||||
help="Import scripts/ directories.",
|
||||
)
|
||||
@click.option("--force", is_flag=True, default=False, help="Re-import existing skills.")
|
||||
@click.option(
|
||||
"--yes-dangerous",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Confirm installing unreviewed skills that request dangerous "
|
||||
"capabilities (shell/network-listen/filesystem-write)."
|
||||
),
|
||||
)
|
||||
def sync(
|
||||
source: str,
|
||||
category: str,
|
||||
@@ -277,6 +300,7 @@ def sync(
|
||||
search: str,
|
||||
with_scripts: bool,
|
||||
force: bool,
|
||||
yes_dangerous: bool,
|
||||
):
|
||||
"""Bulk install + update from a source (or all configured sources)."""
|
||||
console = Console()
|
||||
@@ -343,9 +367,20 @@ def sync(
|
||||
|
||||
installed_count = 0
|
||||
for resolved in skills_to_import:
|
||||
r = importer.import_skill(resolved, with_scripts=with_scripts, force=force)
|
||||
r = importer.import_skill(
|
||||
resolved,
|
||||
with_scripts=with_scripts,
|
||||
force=force,
|
||||
confirm_dangerous=yes_dangerous,
|
||||
)
|
||||
if r.success and not r.skipped:
|
||||
installed_count += 1
|
||||
elif not r.success and r.requires_confirmation:
|
||||
console.print(
|
||||
f" [yellow]Skipped {resolved.name}: requests dangerous "
|
||||
f"capabilities {r.dangerous_capabilities} "
|
||||
"(re-run with --yes-dangerous to install)[/yellow]"
|
||||
)
|
||||
console.print(f" Imported {installed_count}/{len(skills_to_import)} skills")
|
||||
total_installed += installed_count
|
||||
|
||||
|
||||
@@ -124,7 +124,8 @@ class OllamaEmbedder:
|
||||
elif arr.shape[0] != self._dim:
|
||||
logger.warning(
|
||||
"OllamaEmbedder.embed: dim drift (expected %d, got %d)",
|
||||
self._dim, arr.shape[0],
|
||||
self._dim,
|
||||
arr.shape[0],
|
||||
)
|
||||
return None
|
||||
return arr.tobytes()
|
||||
@@ -143,9 +144,7 @@ class OllamaEmbedder:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def decode_embedding(
|
||||
blob: Optional[bytes], *, dtype=None
|
||||
) -> Optional[np.ndarray]:
|
||||
def decode_embedding(blob: Optional[bytes], *, dtype=None) -> Optional[np.ndarray]:
|
||||
"""Reconstruct a 1-D vector from a BLOB written by ``OllamaEmbedder.embed``.
|
||||
|
||||
Returns ``None`` when the input is missing or zero-length so callers can
|
||||
|
||||
@@ -345,9 +345,7 @@ class GranolaConnector(BaseConnector):
|
||||
|
||||
attendees: List[Dict[str, Any]] = note.get("attendees") or []
|
||||
participants: List[str] = [
|
||||
(a.get("email") or "").lower()
|
||||
for a in attendees
|
||||
if a.get("email")
|
||||
(a.get("email") or "").lower() for a in attendees if a.get("email")
|
||||
]
|
||||
participants_raw: List[str] = [
|
||||
a.get("name") or a.get("email") or ""
|
||||
|
||||
@@ -54,13 +54,17 @@ _CALENDAR_REQUEST_TERMS = _CALENDAR_TERMS | {
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_GCALENDAR_GENERIC_TERMS = _UPCOMING_TERMS | _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_GCALENDAR_GENERIC_TERMS = (
|
||||
_UPCOMING_TERMS
|
||||
| _CALENDAR_TERMS
|
||||
| {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
)
|
||||
_QUERY_STOPWORDS = {
|
||||
"a",
|
||||
"all",
|
||||
@@ -636,8 +640,7 @@ class HybridSearch:
|
||||
ids,
|
||||
).fetchall()
|
||||
timestamps = {
|
||||
row["id"]: _parse_timestamp_for_timeline(row["timestamp"])
|
||||
for row in rows
|
||||
row["id"]: _parse_timestamp_for_timeline(row["timestamp"]) for row in rows
|
||||
}
|
||||
|
||||
def _keeps_item(item: Tuple[str, float, float, float]) -> bool:
|
||||
|
||||
@@ -48,7 +48,7 @@ def _derive_source_id(doc: Document) -> str:
|
||||
return doc.source_id
|
||||
prefix = f"{doc.source}:"
|
||||
if doc.doc_id.startswith(prefix):
|
||||
return doc.doc_id[len(prefix):]
|
||||
return doc.doc_id[len(prefix) :]
|
||||
return doc.doc_id
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ def _content_hash(text: str) -> str:
|
||||
"""SHA-256 hex digest of UTF-8-encoded chunk content."""
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.connectors.attachment_store import AttachmentStore
|
||||
|
||||
|
||||
@@ -205,8 +205,7 @@ def _validate_user_token(token: str) -> None:
|
||||
raise SlackTokenError("Slack token is empty.")
|
||||
if token.startswith(_BOT_TOKEN_PREFIX):
|
||||
raise SlackTokenError(
|
||||
"Bot tokens (xoxb-) can't read DMs. "
|
||||
"Use a User OAuth Token (xoxp-) instead."
|
||||
"Bot tokens (xoxb-) can't read DMs. Use a User OAuth Token (xoxp-) instead."
|
||||
)
|
||||
if not token.startswith(_USER_TOKEN_PREFIX):
|
||||
raise SlackTokenError(
|
||||
@@ -436,9 +435,7 @@ class SlackConnector(BaseConnector):
|
||||
all_channels: List[Dict[str, Any]] = []
|
||||
channels_cursor = ""
|
||||
while True:
|
||||
channels_resp = _slack_api_conversations_list(
|
||||
token, cursor=channels_cursor
|
||||
)
|
||||
channels_resp = _slack_api_conversations_list(token, cursor=channels_cursor)
|
||||
if not channels_resp.get("ok", True):
|
||||
err = str(channels_resp.get("error", "list_failed"))
|
||||
self._last_error = f"Slack conversations.list failed: {err}"
|
||||
@@ -446,8 +443,7 @@ class SlackConnector(BaseConnector):
|
||||
return
|
||||
all_channels.extend(channels_resp.get("channels", []))
|
||||
channels_cursor = (
|
||||
channels_resp.get("response_metadata", {}).get("next_cursor", "")
|
||||
or ""
|
||||
channels_resp.get("response_metadata", {}).get("next_cursor", "") or ""
|
||||
)
|
||||
if not channels_cursor:
|
||||
break
|
||||
|
||||
@@ -12,9 +12,18 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from openjarvis.core.paths import (
|
||||
ConfigurationError,
|
||||
@@ -1710,10 +1719,16 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
|
||||
"""Overlay TOML key/value pairs onto a dataclass instance.
|
||||
|
||||
Recursively handles nested dicts when the target attribute is itself
|
||||
a dataclass. Normalises TOML arrays to comma-separated strings — both
|
||||
for dataclass fields annotated as ``str`` and for backward-compat
|
||||
property setters that expect string input.
|
||||
a dataclass, including dict entries in lists of dataclasses. Normalises
|
||||
TOML arrays to comma-separated strings — both for dataclass fields annotated
|
||||
as ``str`` and for backward-compat property setters that expect string input.
|
||||
"""
|
||||
try:
|
||||
type_hints = get_type_hints(type(target))
|
||||
except (NameError, TypeError):
|
||||
# Some config types contain optional runtime-only forward references.
|
||||
type_hints = {}
|
||||
|
||||
for key, value in section.items():
|
||||
if hasattr(target, key):
|
||||
if isinstance(value, dict):
|
||||
@@ -1728,14 +1743,35 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
|
||||
# property setters (e.g. reward_weights, default_tools).
|
||||
if isinstance(value, list):
|
||||
is_str_field = False
|
||||
item_dataclass = None
|
||||
if hasattr(target, "__dataclass_fields__"):
|
||||
field_obj = target.__dataclass_fields__.get(key)
|
||||
if field_obj is not None and field_obj.type in ("str", str):
|
||||
is_str_field = True
|
||||
elif field_obj is None:
|
||||
if field_obj is not None:
|
||||
field_type = type_hints.get(key, field_obj.type)
|
||||
type_args = get_args(field_type)
|
||||
if (
|
||||
get_origin(field_type) is list
|
||||
and len(type_args) == 1
|
||||
and is_dataclass(type_args[0])
|
||||
):
|
||||
item_dataclass = type_args[0]
|
||||
elif field_obj.type in ("str", str):
|
||||
is_str_field = True
|
||||
else:
|
||||
# Property, not a real field — normalise to string
|
||||
is_str_field = True
|
||||
if is_str_field:
|
||||
|
||||
if item_dataclass is not None:
|
||||
converted = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
nested = item_dataclass()
|
||||
_apply_toml_section(nested, item)
|
||||
converted.append(nested)
|
||||
else:
|
||||
converted.append(item)
|
||||
value = converted
|
||||
elif is_str_field:
|
||||
value = ",".join(str(v) for v in value)
|
||||
setattr(target, key, value)
|
||||
|
||||
|
||||
@@ -67,6 +67,24 @@ def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def _validate_credential_key(tool_name: str, key: str) -> None:
|
||||
allowed = TOOL_CREDENTIALS.get(tool_name, [])
|
||||
if key not in allowed:
|
||||
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
|
||||
|
||||
|
||||
def _write_credentials(creds: dict[str, dict[str, str]], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines: list[str] = []
|
||||
for section, kvs in creds.items():
|
||||
lines.append(f"[{section}]")
|
||||
for k, v in kvs.items():
|
||||
lines.append(f'{k} = "{v}"')
|
||||
lines.append("")
|
||||
path.write_text("\n".join(lines))
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def save_credential(
|
||||
tool_name: str,
|
||||
key: str,
|
||||
@@ -75,9 +93,7 @@ def save_credential(
|
||||
path: Path | None = None,
|
||||
) -> None:
|
||||
"""Save a single credential key, validate, write file, and set os.environ."""
|
||||
allowed = TOOL_CREDENTIALS.get(tool_name, [])
|
||||
if key not in allowed:
|
||||
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
|
||||
_validate_credential_key(tool_name, key)
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Credential value must not be empty")
|
||||
@@ -88,20 +104,32 @@ def save_credential(
|
||||
if tool_name not in creds:
|
||||
creds[tool_name] = {}
|
||||
creds[tool_name][key] = stripped
|
||||
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines: list[str] = []
|
||||
for section, kvs in creds.items():
|
||||
lines.append(f"[{section}]")
|
||||
for k, v in kvs.items():
|
||||
lines.append(f'{k} = "{v}"')
|
||||
lines.append("")
|
||||
p.write_text("\n".join(lines))
|
||||
os.chmod(p, 0o600)
|
||||
_write_credentials(creds, p)
|
||||
|
||||
os.environ[key] = stripped
|
||||
|
||||
|
||||
def delete_credential(
|
||||
tool_name: str,
|
||||
key: str,
|
||||
*,
|
||||
path: Path | None = None,
|
||||
) -> None:
|
||||
"""Delete a persisted credential and remove it from the running process."""
|
||||
_validate_credential_key(tool_name, key)
|
||||
p = Path(path) if path else _default_path()
|
||||
with _LOCK:
|
||||
creds = load_credentials(path=p)
|
||||
tool_creds = creds.get(tool_name)
|
||||
if tool_creds is not None:
|
||||
tool_creds.pop(key, None)
|
||||
if not tool_creds:
|
||||
creds.pop(tool_name, None)
|
||||
_write_credentials(creds, p)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
def get_credential_status(tool_name: str) -> dict[str, bool]:
|
||||
"""Return {KEY: bool} for each required key indicating if set in env."""
|
||||
keys = TOOL_CREDENTIALS.get(tool_name, [])
|
||||
|
||||
@@ -9,7 +9,9 @@ import openjarvis.engine.ollama # noqa: F401
|
||||
import openjarvis.engine.openai_compat_engines # noqa: F401
|
||||
from openjarvis.engine._base import (
|
||||
EngineConnectionError,
|
||||
EngineContextLengthError,
|
||||
InferenceEngine,
|
||||
looks_like_context_length_error,
|
||||
messages_to_dicts,
|
||||
)
|
||||
from openjarvis.engine._discovery import discover_engines, discover_models, get_engine
|
||||
@@ -23,9 +25,11 @@ for _optional in ("cloud", "litellm", "gemma_cpp"):
|
||||
|
||||
__all__ = [
|
||||
"EngineConnectionError",
|
||||
"EngineContextLengthError",
|
||||
"InferenceEngine",
|
||||
"discover_engines",
|
||||
"discover_models",
|
||||
"get_engine",
|
||||
"looks_like_context_length_error",
|
||||
"messages_to_dicts",
|
||||
]
|
||||
|
||||
@@ -13,6 +13,46 @@ class EngineConnectionError(Exception):
|
||||
"""Raised when an engine is unreachable."""
|
||||
|
||||
|
||||
class EngineContextLengthError(EngineConnectionError):
|
||||
"""The prompt exceeds the served model's maximum context window.
|
||||
|
||||
Subclasses ``EngineConnectionError`` so existing ``except
|
||||
EngineConnectionError`` handlers keep catching it, while callers that want a
|
||||
distinct, user-facing "conversation too long" message can branch on this type
|
||||
(or the ``is_context_length_error`` marker) instead of surfacing a generic
|
||||
engine failure.
|
||||
"""
|
||||
|
||||
is_context_length_error: bool = True
|
||||
|
||||
|
||||
# Substrings that identify an error body as a context-window overflow (vLLM,
|
||||
# SGLang, and OpenAI-compatible servers phrase this a few different ways).
|
||||
# Every marker is anchored on "context" on purpose: generic phrases like
|
||||
# "please reduce" or "too many tokens" also appear in unrelated 400 bodies
|
||||
# (max_tokens validation, rate limiting, oversized images) and would
|
||||
# misclassify those as "conversation too long".
|
||||
CONTEXT_LENGTH_MARKERS = (
|
||||
"context length",
|
||||
"maximum context",
|
||||
"context window",
|
||||
"maximum_context",
|
||||
"context_length_exceeded",
|
||||
)
|
||||
|
||||
|
||||
def looks_like_context_length_error(text: str) -> bool:
|
||||
"""True when *text* reads like a context-window overflow error.
|
||||
|
||||
The single shared heuristic for recognizing vendor context-overflow
|
||||
phrasings — used by the engine layer (typing upstream 400s), agent error
|
||||
classification, and the server stream bridge, so a new vendor phrasing
|
||||
only ever needs to be added here.
|
||||
"""
|
||||
low = (text or "").lower()
|
||||
return any(marker in low for marker in CONTEXT_LENGTH_MARKERS)
|
||||
|
||||
|
||||
_REASONING_METADATA_KEYS = ("reasoning_content", "thinking")
|
||||
|
||||
|
||||
@@ -80,8 +120,11 @@ def estimate_prompt_tokens(messages: Sequence[Message]) -> int:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONTEXT_LENGTH_MARKERS",
|
||||
"EngineConnectionError",
|
||||
"EngineContextLengthError",
|
||||
"InferenceEngine",
|
||||
"estimate_prompt_tokens",
|
||||
"looks_like_context_length_error",
|
||||
"messages_to_dicts",
|
||||
]
|
||||
|
||||
@@ -35,6 +35,12 @@ def _make_engine(key: str, config: JarvisConfig) -> InferenceEngine:
|
||||
"""Instantiate a registered engine with the appropriate config host."""
|
||||
cls = EngineRegistry.get(key)
|
||||
|
||||
# LiteLLM cannot enumerate every model supported by every provider. Its
|
||||
# list_models() contract therefore advertises the configured default
|
||||
# model, which must be supplied when discovery constructs the engine.
|
||||
if key == "litellm":
|
||||
return cls(default_model=config.intelligence.default_model or None)
|
||||
|
||||
# gemma_cpp: pass config fields instead of host
|
||||
if key == "gemma_cpp":
|
||||
cfg = config.engine.gemma_cpp
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Shared async-HTTP plumbing for engines that stream over httpx.
|
||||
|
||||
Home of the pieces the OpenAI-compat and Ollama engines were each hand-rolling:
|
||||
the async-client factory (with the configured timeout applied), a cached
|
||||
long-lived client so consecutive streams reuse pooled connections instead of
|
||||
paying a fresh TCP/TLS handshake per turn, the transport-error set that maps to
|
||||
``EngineConnectionError``, and the non-2xx → engine-error translation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import NoReturn
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.engine._base import (
|
||||
EngineConnectionError,
|
||||
EngineContextLengthError,
|
||||
looks_like_context_length_error,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Transport failures that map to EngineConnectionError on the streaming paths.
|
||||
# ``RemoteProtocolError``/``ReadError`` cover a server dying MID-STREAM (peer
|
||||
# closed between tokens); a wedged read trips the configured timeout
|
||||
# (TimeoutException). Kept exactly this narrow on purpose:
|
||||
# ``asyncio.CancelledError``/``GeneratorExit`` are NOT ``httpx.TransportError``
|
||||
# subclasses and must keep propagating for correct cancellation.
|
||||
STREAM_TRANSPORT_ERRORS = (
|
||||
httpx.ConnectError,
|
||||
httpx.TimeoutException,
|
||||
httpx.RemoteProtocolError,
|
||||
httpx.ReadError,
|
||||
)
|
||||
|
||||
_CONTEXT_LENGTH_USER_MESSAGE = (
|
||||
"The conversation is too long for the model's context window. "
|
||||
"Start a new chat or shorten the conversation, then try again."
|
||||
)
|
||||
|
||||
|
||||
class AsyncHTTPEngineMixin:
|
||||
"""Async streaming plumbing shared by httpx-backed engines.
|
||||
|
||||
Expects the engine to provide ``engine_id``, ``_host``, ``_timeout``, an
|
||||
``_async_transport`` test seam (``httpx.MockTransport`` in tests, ``None``
|
||||
in production), and optionally ``_headers``.
|
||||
"""
|
||||
|
||||
engine_id: str
|
||||
_host: str
|
||||
_timeout: float
|
||||
_async_transport: httpx.AsyncBaseTransport | None
|
||||
|
||||
# Set True by engines whose upstream reports context-window overflows in
|
||||
# 400 bodies (OpenAI-compat servers). Ollama has no such signal.
|
||||
_stream_400_signals_context_length: bool = False
|
||||
|
||||
# Lazily-created shared client (and the loop it belongs to). Class-level
|
||||
# ``None`` defaults keep engine ``__init__``s free of mixin bookkeeping.
|
||||
_async_client: httpx.AsyncClient | None = None
|
||||
_async_client_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def _make_async_client(self) -> httpx.AsyncClient:
|
||||
"""Build an async client that honours the configured timeout."""
|
||||
return httpx.AsyncClient(
|
||||
base_url=self._host,
|
||||
timeout=self._timeout,
|
||||
headers=getattr(self, "_headers", None),
|
||||
transport=self._async_transport,
|
||||
)
|
||||
|
||||
def _get_async_client(self) -> httpx.AsyncClient:
|
||||
"""Return the shared async client for the running event loop.
|
||||
|
||||
Reusing one client across calls preserves connection pooling — without
|
||||
it every conversation turn pays a fresh TCP (and TLS) handshake. The
|
||||
client is cached per event loop: pooled connections die with their
|
||||
loop, so CLI flows that run ``asyncio.run()`` per turn transparently
|
||||
get a fresh client while a long-lived server loop keeps one pool.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
client = self._async_client
|
||||
if client is None or client.is_closed or self._async_client_loop is not loop:
|
||||
# Any previous client belonged to a finished loop; its pooled
|
||||
# connections are already dead, so just drop the reference.
|
||||
client = self._make_async_client()
|
||||
self._async_client = client
|
||||
self._async_client_loop = loop
|
||||
return client
|
||||
|
||||
def _close_async_client(self) -> None:
|
||||
"""Best-effort close of the shared async client (for ``close()``)."""
|
||||
client = self._async_client
|
||||
loop = self._async_client_loop
|
||||
self._async_client = None
|
||||
self._async_client_loop = None
|
||||
if client is None or client.is_closed:
|
||||
return
|
||||
try:
|
||||
if loop is not None and not loop.is_closed():
|
||||
if loop.is_running():
|
||||
loop.create_task(client.aclose())
|
||||
else:
|
||||
loop.run_until_complete(client.aclose())
|
||||
except Exception: # noqa: BLE001 — cleanup must never mask the close
|
||||
logger.debug("Async client did not close cleanly", exc_info=True)
|
||||
|
||||
def _raise_stream_http_error(self, status: int, detail: str) -> NoReturn:
|
||||
"""Map a non-success streaming HTTP response to a clean engine error."""
|
||||
detail = (detail or "").strip()
|
||||
if (
|
||||
status == 400
|
||||
and self._stream_400_signals_context_length
|
||||
and looks_like_context_length_error(detail)
|
||||
):
|
||||
raise EngineContextLengthError(_CONTEXT_LENGTH_USER_MESSAGE)
|
||||
detail_suffix = f": {detail}" if detail else ""
|
||||
raise EngineConnectionError(
|
||||
f"{self.engine_id} engine at {self._host} returned HTTP "
|
||||
f"{status}{detail_suffix}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AsyncHTTPEngineMixin", "STREAM_TRANSPORT_ERRORS"]
|
||||
@@ -12,18 +12,27 @@ import httpx
|
||||
from openjarvis.core.types import Message
|
||||
from openjarvis.engine._base import (
|
||||
EngineConnectionError,
|
||||
EngineContextLengthError,
|
||||
InferenceEngine,
|
||||
estimate_prompt_tokens,
|
||||
messages_to_dicts,
|
||||
)
|
||||
from openjarvis.engine._http_async import (
|
||||
STREAM_TRANSPORT_ERRORS,
|
||||
AsyncHTTPEngineMixin,
|
||||
)
|
||||
from openjarvis.engine._stubs import StreamChunk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _OpenAICompatibleEngine(InferenceEngine):
|
||||
class _OpenAICompatibleEngine(AsyncHTTPEngineMixin, InferenceEngine):
|
||||
"""Base for engines that serve the OpenAI ``/v1/chat/completions`` API."""
|
||||
|
||||
# vLLM/SGLang report context-window overflows in 400 bodies; the shared
|
||||
# ``_raise_stream_http_error`` types those as ``EngineContextLengthError``.
|
||||
_stream_400_signals_context_length = True
|
||||
|
||||
engine_id: str = ""
|
||||
_default_host: str = "http://localhost:8000"
|
||||
_api_prefix: str = "/v1"
|
||||
@@ -50,6 +59,16 @@ class _OpenAICompatibleEngine(InferenceEngine):
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
|
||||
)
|
||||
# Used by the shared async streaming plumbing (AsyncHTTPEngineMixin) so
|
||||
# the bounded request timeout is applied to streaming reads, not just
|
||||
# the synchronous methods (a wedged token read fails at ``timeout``
|
||||
# rather than hanging the caller for the httpx default).
|
||||
self._timeout = timeout
|
||||
self._headers = headers
|
||||
# Injection seam for tests: an ``httpx.MockTransport`` swapped in here lets
|
||||
# the async stream path be exercised with a mocked transport and no real
|
||||
# server. ``None`` in production so httpx uses its default networking.
|
||||
self._async_transport: httpx.AsyncBaseTransport | None = None
|
||||
self._client = httpx.Client(
|
||||
base_url=self._host, timeout=timeout, headers=headers
|
||||
)
|
||||
@@ -168,11 +187,26 @@ class _OpenAICompatibleEngine(InferenceEngine):
|
||||
# Default to tool_choice=auto when tools are provided
|
||||
if "tools" in payload and "tool_choice" not in payload:
|
||||
payload["tool_choice"] = "auto"
|
||||
url = f"{self._api_prefix}/chat/completions"
|
||||
try:
|
||||
url = f"{self._api_prefix}/chat/completions"
|
||||
with self._client.stream("POST", url, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
# ASYNC streaming: ``httpx.AsyncClient`` + ``aiter_lines`` never
|
||||
# blocks the event loop between tokens (the previous SYNC
|
||||
# ``httpx.Client`` + ``iter_lines`` inside this ``async def`` blocked
|
||||
# the single uvicorn worker on every inter-token wait, serializing all
|
||||
# concurrent chats and letting one wedged read freeze the whole API).
|
||||
# The shared client keeps pooled connections across turns.
|
||||
client = self._get_async_client()
|
||||
async with client.stream("POST", url, json=payload) as resp:
|
||||
# ``not is_success`` covers 3xx as well as 4xx/5xx. With
|
||||
# ``follow_redirects`` off (the default) an unexpected redirect
|
||||
# would otherwise fall through to ``aiter_lines`` and surface as
|
||||
# a silent EMPTY stream instead of a clean engine error.
|
||||
if not resp.is_success:
|
||||
# Load the (short) error body before touching ``.text``:
|
||||
# a streaming response is otherwise unread.
|
||||
await resp.aread()
|
||||
self._raise_stream_http_error(resp.status_code, resp.text)
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[len("data:") :].strip()
|
||||
@@ -186,7 +220,11 @@ class _OpenAICompatibleEngine(InferenceEngine):
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
||||
except STREAM_TRANSPORT_ERRORS as exc:
|
||||
# A wedged upstream read trips ``timeout`` (ReadTimeout) and is mapped
|
||||
# here, so the request fails cleanly at the configured bound instead
|
||||
# of hanging indefinitely (see STREAM_TRANSPORT_ERRORS for why the
|
||||
# set is exactly this narrow).
|
||||
raise EngineConnectionError(
|
||||
f"{self.engine_id} engine not reachable at {self._host}"
|
||||
) from exc
|
||||
@@ -212,11 +250,20 @@ class _OpenAICompatibleEngine(InferenceEngine):
|
||||
}
|
||||
if "tools" in payload and "tool_choice" not in payload:
|
||||
payload["tool_choice"] = "auto"
|
||||
url = f"{self._api_prefix}/chat/completions"
|
||||
try:
|
||||
url = f"{self._api_prefix}/chat/completions"
|
||||
with self._client.stream("POST", url, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
# ASYNC streaming (see ``stream``): non-blocking shared client so
|
||||
# rich streaming never stalls the event loop and honours ``timeout``.
|
||||
client = self._get_async_client()
|
||||
async with client.stream("POST", url, json=payload) as resp:
|
||||
# ``not is_success`` covers 3xx as well as 4xx/5xx. With
|
||||
# ``follow_redirects`` off (the default) an unexpected redirect
|
||||
# would otherwise fall through to ``aiter_lines`` and surface as
|
||||
# a silent EMPTY stream instead of a clean engine error.
|
||||
if not resp.is_success:
|
||||
await resp.aread()
|
||||
self._raise_stream_http_error(resp.status_code, resp.text)
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[len("data:") :].strip()
|
||||
@@ -240,7 +287,10 @@ class _OpenAICompatibleEngine(InferenceEngine):
|
||||
finish_reason=finish,
|
||||
usage=usage,
|
||||
)
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
||||
except STREAM_TRANSPORT_ERRORS as exc:
|
||||
# See ``stream``: transport failures (incl. a mid-stream server
|
||||
# disconnect) map to a clean error; the set is kept narrow so
|
||||
# cancellation still propagates.
|
||||
raise EngineConnectionError(
|
||||
f"{self.engine_id} engine not reachable at {self._host}"
|
||||
) from exc
|
||||
@@ -279,6 +329,9 @@ class _OpenAICompatibleEngine(InferenceEngine):
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
self._close_async_client()
|
||||
|
||||
|
||||
__all__ = ["_OpenAICompatibleEngine"]
|
||||
# ``EngineContextLengthError`` moved to ``openjarvis.engine._base``; re-exported
|
||||
# here for callers/tests that import it from this module.
|
||||
__all__ = ["_OpenAICompatibleEngine", "EngineContextLengthError"]
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
@@ -1305,6 +1306,160 @@ class CloudEngine(InferenceEngine):
|
||||
if chunk.text:
|
||||
yield chunk.text
|
||||
|
||||
async def _stream_full_google(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
"""Stream Google text and function-call parts as full chunks."""
|
||||
if self._google_client is None:
|
||||
raise EngineConnectionError("Google client not available")
|
||||
|
||||
system_text = ""
|
||||
contents: List[Dict[str, Any]] = []
|
||||
for message in messages:
|
||||
if message.role.value == "system":
|
||||
system_text = message.content
|
||||
elif message.role.value == "tool":
|
||||
function_response = {
|
||||
"function_response": {
|
||||
"name": message.name or "unknown",
|
||||
"response": {"result": message.content},
|
||||
}
|
||||
}
|
||||
if (
|
||||
contents
|
||||
and contents[-1]["role"] == "user"
|
||||
and contents[-1]["parts"]
|
||||
and "function_response" in contents[-1]["parts"][-1]
|
||||
):
|
||||
contents[-1]["parts"].append(function_response)
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [function_response]})
|
||||
elif message.role.value == "assistant" and message.tool_calls:
|
||||
parts: List[Dict[str, Any]] = []
|
||||
if message.content:
|
||||
parts.append({"text": message.content})
|
||||
for tool_call in message.tool_calls:
|
||||
args = tool_call.arguments
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {"input": args}
|
||||
function_call_part: Dict[str, Any] = {
|
||||
"function_call": {
|
||||
"name": tool_call.name,
|
||||
"args": args if isinstance(args, dict) else {},
|
||||
}
|
||||
}
|
||||
signature = self._thought_sigs.get(tool_call.id)
|
||||
if signature is not None:
|
||||
function_call_part["thought_signature"] = signature
|
||||
parts.append(function_call_part)
|
||||
contents.append({"role": "model", "parts": parts})
|
||||
elif message.role.value == "assistant":
|
||||
contents.append({"role": "model", "parts": [{"text": message.content}]})
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [{"text": message.content}]})
|
||||
|
||||
from google.genai import types as genai_types
|
||||
|
||||
config = genai_types.GenerateContentConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
)
|
||||
if system_text:
|
||||
config.system_instruction = system_text
|
||||
|
||||
tools = kwargs.pop("tools", None)
|
||||
if tools:
|
||||
config.tools = [{"function_declarations": _convert_tools_to_google(tools)}]
|
||||
|
||||
tool_call_count = 0
|
||||
stream_id = uuid.uuid4().hex
|
||||
final_usage: Dict[str, Any] | None = None
|
||||
for chunk in self._google_client.models.generate_content_stream(
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=config,
|
||||
):
|
||||
usage_metadata = getattr(chunk, "usage_metadata", None)
|
||||
if usage_metadata is not None:
|
||||
prompt_tokens = getattr(usage_metadata, "prompt_token_count", 0) or 0
|
||||
completion_tokens = (
|
||||
getattr(usage_metadata, "candidates_token_count", 0) or 0
|
||||
)
|
||||
final_usage = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
}
|
||||
|
||||
candidates = getattr(chunk, "candidates", None)
|
||||
parts = []
|
||||
if candidates:
|
||||
parts = getattr(candidates[0].content, "parts", []) or []
|
||||
|
||||
if parts:
|
||||
text_found = False
|
||||
calls: List[Dict[str, Any]] = []
|
||||
for part in parts:
|
||||
text = getattr(part, "text", None)
|
||||
if text:
|
||||
text_found = True
|
||||
yield StreamChunk(content=text)
|
||||
|
||||
function_call = getattr(part, "function_call", None)
|
||||
if function_call:
|
||||
name = getattr(function_call, "name", "")
|
||||
raw_args = getattr(function_call, "args", {})
|
||||
args = dict(raw_args) if hasattr(raw_args, "items") else {}
|
||||
# Gemini emits complete function-call parts, so each part is
|
||||
# a distinct invocation. The same function may legitimately
|
||||
# be called more than once in a parallel response.
|
||||
tool_index = tool_call_count
|
||||
# The engine is shared across server requests, and saved
|
||||
# thought signatures are keyed by tool-call ID. Include a
|
||||
# per-stream nonce so concurrent conversations cannot
|
||||
# overwrite each other's signatures.
|
||||
tool_id = f"google_{stream_id}_{tool_index}"
|
||||
tool_call_count += 1
|
||||
tool_call = {
|
||||
"index": tool_index,
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": json.dumps(args),
|
||||
},
|
||||
}
|
||||
calls.append(tool_call)
|
||||
signature = getattr(part, "thought_signature", None)
|
||||
if signature is not None:
|
||||
tool_call["thought_signature"] = signature
|
||||
self._thought_sigs[tool_id] = signature
|
||||
if calls:
|
||||
yield StreamChunk(tool_calls=calls)
|
||||
if text_found:
|
||||
continue
|
||||
|
||||
try:
|
||||
text = chunk.text
|
||||
except (AttributeError, ValueError):
|
||||
text = None
|
||||
if text:
|
||||
yield StreamChunk(content=text)
|
||||
|
||||
yield StreamChunk(
|
||||
finish_reason="tool_calls" if tool_call_count else "stop",
|
||||
usage=final_usage,
|
||||
)
|
||||
|
||||
async def _stream_openrouter(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
@@ -1600,7 +1755,7 @@ class CloudEngine(InferenceEngine):
|
||||
async for chunk in self._stream_full_anthropic(messages, **kw):
|
||||
yield chunk
|
||||
elif _is_google_model(model):
|
||||
async for chunk in super().stream_full(messages, **kw):
|
||||
async for chunk in self._stream_full_google(messages, **kw):
|
||||
yield chunk
|
||||
else:
|
||||
async for chunk in self._stream_full_openai(messages, **kw):
|
||||
|
||||
@@ -123,8 +123,12 @@ class LiteLLMEngine(InferenceEngine):
|
||||
call_kwargs["api_base"] = self._api_base
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
resp = litellm.completion(**call_kwargs)
|
||||
for chunk in resp:
|
||||
# ``acompletion`` + ``async for``: the sync ``litellm.completion`` used
|
||||
# before made a blocking network call (and blocking per-chunk reads)
|
||||
# inside this ``async def``, stalling the whole event loop between
|
||||
# tokens — the same bug the httpx engines' streaming paths had.
|
||||
resp = await litellm.acompletion(**call_kwargs)
|
||||
async for chunk in resp:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
@@ -26,16 +26,19 @@ class MultiEngine(InferenceEngine):
|
||||
def __init__(self, engines: list[tuple[str, InferenceEngine]]) -> None:
|
||||
self._engines = engines
|
||||
self._model_map: Dict[str, InferenceEngine] = {}
|
||||
self._model_key_map: Dict[str, str] = {}
|
||||
self._refresh_map()
|
||||
|
||||
def _refresh_map(self) -> None:
|
||||
self._model_map.clear()
|
||||
for _key, engine in self._engines:
|
||||
self._model_key_map.clear()
|
||||
for key, engine in self._engines:
|
||||
try:
|
||||
for model_id in engine.list_models():
|
||||
self._model_map[model_id] = engine
|
||||
self._model_key_map[model_id] = key
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to list models for %s: %s", _key, exc)
|
||||
logger.debug("Failed to list models for %s: %s", key, exc)
|
||||
|
||||
_CLOUD_PREFIXES = ("gpt-", "o1-", "o3-", "o4-", "claude-", "gemini-", "openrouter/")
|
||||
|
||||
@@ -117,6 +120,14 @@ class MultiEngine(InferenceEngine):
|
||||
self._refresh_map()
|
||||
return list(self._model_map.keys())
|
||||
|
||||
def engine_key_for(self, model: str) -> str | None:
|
||||
"""Return the registry key of the engine advertising *model*."""
|
||||
key = self._model_key_map.get(model)
|
||||
if key is not None:
|
||||
return key
|
||||
self._refresh_map()
|
||||
return self._model_key_map.get(model)
|
||||
|
||||
def health(self) -> bool:
|
||||
return any(engine.health() for _key, engine in self._engines)
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ from openjarvis.engine._base import (
|
||||
estimate_prompt_tokens,
|
||||
messages_to_dicts,
|
||||
)
|
||||
from openjarvis.engine._http_async import (
|
||||
STREAM_TRANSPORT_ERRORS,
|
||||
AsyncHTTPEngineMixin,
|
||||
)
|
||||
from openjarvis.engine._stubs import StreamChunk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -80,11 +84,15 @@ def _default_num_ctx() -> int:
|
||||
|
||||
|
||||
@EngineRegistry.register("ollama")
|
||||
class OllamaEngine(InferenceEngine):
|
||||
class OllamaEngine(AsyncHTTPEngineMixin, InferenceEngine):
|
||||
"""Ollama backend via its native HTTP API."""
|
||||
|
||||
engine_id = "ollama"
|
||||
|
||||
# Ollama has no context-length overflow signal in its 400 bodies, so the
|
||||
# shared ``_raise_stream_http_error`` keeps its default (no
|
||||
# ``EngineContextLengthError`` branch, unlike the OpenAI-compat engines).
|
||||
|
||||
_DEFAULT_HOST = "http://localhost:11434"
|
||||
|
||||
def __init__(
|
||||
@@ -98,6 +106,14 @@ class OllamaEngine(InferenceEngine):
|
||||
env_host = os.environ.get("OLLAMA_HOST")
|
||||
host = env_host or self._DEFAULT_HOST
|
||||
self._host = host.rstrip("/")
|
||||
# Used by the shared async streaming plumbing (AsyncHTTPEngineMixin) so a
|
||||
# wedged token read is bounded by ``timeout`` instead of hanging the
|
||||
# single event loop for the httpx default.
|
||||
self._timeout = timeout
|
||||
# Injection seam for tests: an ``httpx.MockTransport`` swapped in here drives
|
||||
# the async stream path with no real Ollama server. ``None`` in production so
|
||||
# httpx uses its default networking.
|
||||
self._async_transport: httpx.AsyncBaseTransport | None = None
|
||||
self._client = httpx.Client(base_url=self._host, timeout=timeout)
|
||||
# Last stream usage — captured from Ollama's final chunk
|
||||
self._last_stream_usage: Dict[str, int] = {}
|
||||
@@ -263,9 +279,26 @@ class OllamaEngine(InferenceEngine):
|
||||
elif kwargs["think"] is not None:
|
||||
payload["think"] = kwargs["think"]
|
||||
try:
|
||||
with self._client.stream("POST", "/api/chat", json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
# ASYNC streaming: ``httpx.AsyncClient`` + ``aiter_lines`` never
|
||||
# blocks the event loop between tokens (the previous SYNC
|
||||
# ``self._client`` + ``iter_lines`` inside this ``async def`` blocked
|
||||
# the single uvicorn worker on every inter-token wait, serializing all
|
||||
# concurrent chats and letting one wedged read freeze the whole API).
|
||||
# The shared client keeps pooled connections across turns.
|
||||
client = self._get_async_client()
|
||||
async with client.stream("POST", "/api/chat", json=payload) as resp:
|
||||
# ``not is_success`` covers 3xx as well as 4xx/5xx and maps
|
||||
# to ``EngineConnectionError`` (matching the OpenAI-compat
|
||||
# path) instead of leaking a raw ``httpx.HTTPStatusError``.
|
||||
# With redirects off (the default) an unexpected 3xx would
|
||||
# otherwise fall through to ``aiter_lines`` and surface as a
|
||||
# silent EMPTY stream rather than a clean engine error.
|
||||
if not resp.is_success:
|
||||
# Read the (short) error body before touching ``.text``:
|
||||
# a streaming response is otherwise unread.
|
||||
await resp.aread()
|
||||
self._raise_stream_http_error(resp.status_code, resp.text)
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
@@ -290,7 +323,10 @@ class OllamaEngine(InferenceEngine):
|
||||
"total_tokens": full_prompt + comp,
|
||||
}
|
||||
break
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
||||
except STREAM_TRANSPORT_ERRORS as exc:
|
||||
# Transport failures (incl. a mid-stream server disconnect) map to a
|
||||
# clean error; the set is kept narrow (see STREAM_TRANSPORT_ERRORS)
|
||||
# so cancellation still propagates.
|
||||
raise EngineConnectionError(
|
||||
f"Ollama not reachable at {self._host}"
|
||||
) from exc
|
||||
@@ -356,19 +392,34 @@ class OllamaEngine(InferenceEngine):
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
"""Execute the streaming request and yield parsed StreamChunks."""
|
||||
try:
|
||||
with self._client.stream("POST", "/api/chat", json=payload) as resp:
|
||||
# ASYNC streaming (see ``stream``): shared ``AsyncClient`` +
|
||||
# ``aiter_lines`` so rich streaming never stalls the event loop and
|
||||
# honours ``timeout``.
|
||||
client = self._get_async_client()
|
||||
async with client.stream("POST", "/api/chat", json=payload) as resp:
|
||||
if resp.status_code == 400 and retry_without_tools:
|
||||
# Model doesn't support tools — retry without them.
|
||||
# PRESERVED: this specific 400 path must still trigger the
|
||||
# tools-less retry; only OTHER non-2xx responses map to
|
||||
# EngineConnectionError below.
|
||||
payload.pop("tools", None)
|
||||
async for c in self._run_stream(
|
||||
payload, messages, retry_without_tools=False
|
||||
):
|
||||
yield c
|
||||
return
|
||||
resp.raise_for_status()
|
||||
# ``not is_success`` covers 3xx as well as 4xx/5xx and maps
|
||||
# to ``EngineConnectionError`` (matching the OpenAI-compat
|
||||
# path) instead of leaking a raw ``httpx.HTTPStatusError``.
|
||||
# With redirects off (the default) an unexpected 3xx would
|
||||
# otherwise fall through to ``aiter_lines`` and surface as a
|
||||
# silent EMPTY stream rather than a clean engine error.
|
||||
if not resp.is_success:
|
||||
await resp.aread()
|
||||
self._raise_stream_http_error(resp.status_code, resp.text)
|
||||
|
||||
finish_reason: str | None = None
|
||||
for line in resp.iter_lines():
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
@@ -441,7 +492,10 @@ class OllamaEngine(InferenceEngine):
|
||||
usage=dict(self._last_stream_usage),
|
||||
)
|
||||
break
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
||||
except STREAM_TRANSPORT_ERRORS as exc:
|
||||
# See ``stream``: transport failures (incl. a mid-stream server
|
||||
# disconnect) map to a clean error; the set is kept narrow so
|
||||
# cancellation still propagates.
|
||||
raise EngineConnectionError(
|
||||
f"Ollama not reachable at {self._host}"
|
||||
) from exc
|
||||
@@ -474,6 +528,7 @@ class OllamaEngine(InferenceEngine):
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
self._close_async_client()
|
||||
|
||||
|
||||
__all__ = ["OllamaEngine"]
|
||||
|
||||
@@ -203,6 +203,7 @@ def _try_start_nvml() -> Optional[_Sampler]:
|
||||
try:
|
||||
# Suppress legacy pynvml deprecation FutureWarning (#389).
|
||||
import warnings as _warnings
|
||||
|
||||
with _warnings.catch_warnings():
|
||||
_warnings.filterwarnings(
|
||||
"ignore",
|
||||
|
||||
@@ -175,8 +175,7 @@ class EvalRunner:
|
||||
before = len(records)
|
||||
records = [r for r in records if r.record_id in wanted]
|
||||
LOGGER.info(
|
||||
"Filtering %s to %d/%d records via record_ids "
|
||||
"(first 3: %s)",
|
||||
"Filtering %s to %d/%d records via record_ids (first 3: %s)",
|
||||
cfg.benchmark,
|
||||
len(records),
|
||||
before,
|
||||
@@ -324,8 +323,7 @@ class EvalRunner:
|
||||
energy_joules=energy_j,
|
||||
power_watts=power_w,
|
||||
gpu_utilization_pct=full.get("gpu_utilization_pct", 0.0) or 0.0,
|
||||
throughput_tok_per_sec=full.get("throughput_tok_per_sec", 0.0)
|
||||
or 0.0,
|
||||
throughput_tok_per_sec=full.get("throughput_tok_per_sec", 0.0) or 0.0,
|
||||
trace_data=full.get("trace_data"),
|
||||
framework=full.get(
|
||||
"framework",
|
||||
@@ -960,9 +958,7 @@ class EvalRunner:
|
||||
|
||||
# Continuous-score reporting: skip None (errored) entries; clamp values
|
||||
# outside [0,1] are already handled in _extract_continuous_score.
|
||||
cont_scores = [
|
||||
float(r.score) for r in results if r.score is not None
|
||||
]
|
||||
cont_scores = [float(r.score) for r in results if r.score is not None]
|
||||
if cont_scores:
|
||||
mean_cont = sum(cont_scores) / len(cont_scores)
|
||||
median_cont = statistics.median(cont_scores)
|
||||
|
||||
@@ -85,7 +85,12 @@ class TerminalBenchV21Dataset(DatasetProvider):
|
||||
"git binary not found. Install git to clone TerminalBench V2.1 tasks."
|
||||
)
|
||||
self._repo_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
LOGGER.info("Cloning %s (branch %s) into %s", self._repo_url, self._branch, self._repo_dir)
|
||||
LOGGER.info(
|
||||
"Cloning %s (branch %s) into %s",
|
||||
self._repo_url,
|
||||
self._branch,
|
||||
self._repo_dir,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
@@ -110,9 +115,7 @@ class TerminalBenchV21Dataset(DatasetProvider):
|
||||
) -> None:
|
||||
repo = self._ensure_repo()
|
||||
task_dirs = sorted(
|
||||
d
|
||||
for d in repo.iterdir()
|
||||
if d.is_dir() and (d / "task.toml").exists()
|
||||
d for d in repo.iterdir() if d.is_dir() and (d / "task.toml").exists()
|
||||
)
|
||||
|
||||
if self._task_ids:
|
||||
|
||||
@@ -49,8 +49,7 @@ class TerminalBenchV21TaskEnv:
|
||||
task_dir = self._metadata.get("task_dir")
|
||||
if not docker_image or not task_dir:
|
||||
raise ValueError(
|
||||
"TerminalBenchV21TaskEnv missing 'docker_image' or 'task_dir' "
|
||||
"metadata"
|
||||
"TerminalBenchV21TaskEnv missing 'docker_image' or 'task_dir' metadata"
|
||||
)
|
||||
|
||||
tests_dir = Path(task_dir) / "tests"
|
||||
@@ -90,9 +89,7 @@ class TerminalBenchV21TaskEnv:
|
||||
)
|
||||
if start.returncode != 0:
|
||||
self._metadata["tbv21_env_error"] = start.stderr[:500]
|
||||
raise RuntimeError(
|
||||
f"docker run failed for {task_id}: {start.stderr[:300]}"
|
||||
)
|
||||
raise RuntimeError(f"docker run failed for {task_id}: {start.stderr[:300]}")
|
||||
|
||||
self._started = True
|
||||
self._metadata["tbv21_container"] = name
|
||||
|
||||
@@ -120,7 +120,6 @@ Be a rigorous evaluator. Reserve scores of 9-10 for genuinely excellent work.
|
||||
A score of 5 represents adequate but unremarkable quality."""
|
||||
|
||||
|
||||
|
||||
# Optional permissive JSON parser (json5 if available; fallback otherwise).
|
||||
try:
|
||||
import json5 as _json5 # type: ignore[import-not-found]
|
||||
@@ -147,7 +146,7 @@ def _escape_newlines_inside_strings(text: str) -> str:
|
||||
out.append(ch)
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == "\"":
|
||||
if ch == '"':
|
||||
out.append(ch)
|
||||
in_string = False
|
||||
continue
|
||||
@@ -162,7 +161,7 @@ def _escape_newlines_inside_strings(text: str) -> str:
|
||||
continue
|
||||
out.append(ch)
|
||||
else:
|
||||
if ch == "\"":
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
@@ -217,7 +216,7 @@ def _parse_judge_response(raw: str) -> Dict[str, Any]:
|
||||
esc = False
|
||||
elif char == "\\":
|
||||
esc = True
|
||||
elif char == "\"":
|
||||
elif char == '"':
|
||||
in_str = False
|
||||
continue
|
||||
if char == "{":
|
||||
@@ -226,7 +225,7 @@ def _parse_judge_response(raw: str) -> Dict[str, Any]:
|
||||
depth += 1
|
||||
if depth > 0:
|
||||
current.append(char)
|
||||
if char == "\"" and depth > 0:
|
||||
if char == '"' and depth > 0:
|
||||
in_str = True
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.mcp.protocol import MCPError, MCPRequest, MCPResponse
|
||||
@@ -24,18 +25,31 @@ class MCPClient:
|
||||
self._initialized = False
|
||||
self._capabilities: Dict[str, Any] = {}
|
||||
self._id_counter = itertools.count(1)
|
||||
# A client may be shared by server, scheduled, and channel agents.
|
||||
# Keep each transport request/response exchange atomic so stdio
|
||||
# readers cannot consume another thread's JSON-RPC response.
|
||||
self._request_lock = threading.RLock()
|
||||
# Closing must not wait for ``_request_lock``: transport.close() is
|
||||
# what interrupts a request that is blocked in a transport read.
|
||||
# An event lets queued requests fail before touching that transport,
|
||||
# while this separate lock keeps close itself idempotent.
|
||||
self._closed = threading.Event()
|
||||
self._transport_closed = threading.Event()
|
||||
self._close_lock = threading.Lock()
|
||||
|
||||
def _next_id(self) -> int:
|
||||
return next(self._id_counter)
|
||||
|
||||
def _send(self, method: str, params: Dict[str, Any] | None = None) -> MCPResponse:
|
||||
"""Send a request and check for errors."""
|
||||
request = MCPRequest(
|
||||
method=method,
|
||||
params=params or {},
|
||||
id=self._next_id(),
|
||||
)
|
||||
response = self._transport.send(request)
|
||||
with self._request_lock:
|
||||
self._raise_if_closed()
|
||||
request = MCPRequest(
|
||||
method=method,
|
||||
params=params or {},
|
||||
id=self._next_id(),
|
||||
)
|
||||
response = self._transport.send(request)
|
||||
if response.error is not None:
|
||||
raise MCPError(
|
||||
code=response.error.get("code", -1),
|
||||
@@ -44,6 +58,10 @@ class MCPClient:
|
||||
)
|
||||
return response
|
||||
|
||||
def _raise_if_closed(self) -> None:
|
||||
if self._closed.is_set():
|
||||
raise RuntimeError("MCP client is closed")
|
||||
|
||||
def initialize(self) -> Dict[str, Any]:
|
||||
"""Perform the MCP initialize handshake.
|
||||
|
||||
@@ -75,7 +93,9 @@ class MCPClient:
|
||||
params=params or {},
|
||||
id=None, # None → no id field in JSON (notification)
|
||||
)
|
||||
self._transport.send_notification(request)
|
||||
with self._request_lock:
|
||||
self._raise_if_closed()
|
||||
self._transport.send_notification(request)
|
||||
|
||||
def list_tools(self) -> List[ToolSpec]:
|
||||
"""Discover available tools from the server.
|
||||
@@ -114,7 +134,15 @@ class MCPClient:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the transport connection."""
|
||||
self._transport.close()
|
||||
# Do not acquire _request_lock here. A transport request can be stuck
|
||||
# waiting for a server response, and closing the underlying transport
|
||||
# is the mechanism that unblocks it.
|
||||
with self._close_lock:
|
||||
if self._transport_closed.is_set():
|
||||
return
|
||||
self._closed.set()
|
||||
self._transport.close()
|
||||
self._transport_closed.set()
|
||||
|
||||
def __enter__(self) -> MCPClient:
|
||||
return self
|
||||
|
||||
@@ -82,9 +82,7 @@ def load_mcp_tools_from_config(
|
||||
|
||||
for server_cfg in server_list:
|
||||
try:
|
||||
cfg = (
|
||||
json.loads(server_cfg) if isinstance(server_cfg, str) else server_cfg
|
||||
)
|
||||
cfg = json.loads(server_cfg) if isinstance(server_cfg, str) else server_cfg
|
||||
name = cfg.get("name", "<unnamed>")
|
||||
url = cfg.get("url")
|
||||
token = cfg.get("token")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Operator manager — lifecycle management for autonomous operators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -271,20 +272,14 @@ class OperatorManager:
|
||||
"total_cost": summary.total_cost,
|
||||
"total_latency": summary.total_latency,
|
||||
"total_energy_joules": summary.total_energy_joules,
|
||||
"avg_throughput_tok_per_sec": (
|
||||
summary.avg_throughput_tok_per_sec
|
||||
),
|
||||
"avg_throughput_tok_per_sec": (summary.avg_throughput_tok_per_sec),
|
||||
"avg_gpu_utilization_pct": summary.avg_gpu_utilization_pct,
|
||||
"avg_energy_per_output_token_joules": (
|
||||
summary.avg_energy_per_output_token_joules
|
||||
),
|
||||
"avg_throughput_per_watt": summary.avg_throughput_per_watt,
|
||||
"total_prefill_energy_joules": (
|
||||
summary.total_prefill_energy_joules
|
||||
),
|
||||
"total_decode_energy_joules": (
|
||||
summary.total_decode_energy_joules
|
||||
),
|
||||
"total_prefill_energy_joules": (summary.total_prefill_energy_joules),
|
||||
"total_decode_energy_joules": (summary.total_decode_energy_joules),
|
||||
"avg_mean_itl_ms": summary.avg_mean_itl_ms,
|
||||
"avg_median_itl_ms": summary.avg_median_itl_ms,
|
||||
"avg_p95_itl_ms": summary.avg_p95_itl_ms,
|
||||
|
||||
@@ -15,7 +15,7 @@ HARD RULE: Every reply MUST be ≤280 characters. Count before sending.
|
||||
- GitHub: https://github.com/open-jarvis/OpenJarvis
|
||||
- Docs: https://open-jarvis.github.io/OpenJarvis/
|
||||
- Discord: https://discord.gg/wfXEkpPX
|
||||
- Blog: https://scalingintelligence.stanford.edu/blogs/openjarvis/
|
||||
- Blog: https://openjarvis.stanford.edu/
|
||||
- Install: `git clone https://github.com/open-jarvis/OpenJarvis.git && cd OpenJarvis && uv sync`
|
||||
- CLI commands (ONLY these exist):
|
||||
- `jarvis init` — auto-detects hardware, configures engine
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -306,9 +306,7 @@ async def memory_index(req: MemoryIndexRequest, request: Request):
|
||||
for d in workspace.split(os.pathsep)
|
||||
if d.strip()
|
||||
]
|
||||
if not any(
|
||||
target == root or root in target.parents for root in roots
|
||||
):
|
||||
if not any(target == root or root in target.parents for root in roots):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path is outside the allowed workspace directories.",
|
||||
@@ -743,8 +741,11 @@ async def websocket_chat_stream(websocket: WebSocket):
|
||||
)
|
||||
except TypeError:
|
||||
# stream() didn't return an iterable; fall back to
|
||||
# generate()
|
||||
result = engine.generate(messages, model=model)
|
||||
# generate(). It makes a blocking upstream call, so run
|
||||
# it in a worker thread to keep the event loop free.
|
||||
result = await asyncio.to_thread(
|
||||
engine.generate, messages, model=model
|
||||
)
|
||||
content = (
|
||||
result.get("content", "")
|
||||
if isinstance(
|
||||
@@ -769,8 +770,11 @@ async def websocket_chat_stream(websocket: WebSocket):
|
||||
ended_at=_time.time(),
|
||||
)
|
||||
else:
|
||||
# No stream method — single-shot generate
|
||||
result = engine.generate(messages, model=model)
|
||||
# No stream method — single-shot generate. Blocking upstream
|
||||
# call, so run in a worker thread to keep the event loop free.
|
||||
result = await asyncio.to_thread(
|
||||
engine.generate, messages, model=model
|
||||
)
|
||||
content = (
|
||||
result.get("content", "")
|
||||
if isinstance(
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI
|
||||
@@ -21,6 +22,8 @@ from openjarvis.server.routes import router
|
||||
from openjarvis.server.upload_router import router as upload_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_MANAGED_SHUTDOWN_GRACE_SECONDS = 0.25
|
||||
_MANAGED_SHUTDOWN_DRAIN_SECONDS = 10.0
|
||||
|
||||
|
||||
def _restore_sendblue_bindings(app: FastAPI) -> None:
|
||||
@@ -151,10 +154,13 @@ def create_app(
|
||||
channel_bridge=None,
|
||||
config=None,
|
||||
memory_backend=None,
|
||||
own_memory_backend: bool = False,
|
||||
memory_service=None,
|
||||
speech_backend=None,
|
||||
agent_manager=None,
|
||||
agent_scheduler=None,
|
||||
mcp_tools=None,
|
||||
mcp_clients=None,
|
||||
api_key: str = "",
|
||||
webhook_config: dict | None = None,
|
||||
cors_origins: list[str] | None = None,
|
||||
@@ -221,16 +227,129 @@ def create_app(
|
||||
)
|
||||
app.state.channel_bridge = channel_bridge
|
||||
app.state.config = config
|
||||
app.state._memory_backend_lock = threading.Lock()
|
||||
app.state.memory_backend = memory_backend
|
||||
app.state._owns_memory_backend = bool(own_memory_backend)
|
||||
app.state.memory_service = memory_service
|
||||
app.state.speech_backend = speech_backend
|
||||
app.state.agent_manager = agent_manager
|
||||
app.state.agent_scheduler = agent_scheduler
|
||||
app.state.mcp_tools = list(mcp_tools or [])
|
||||
app.state._mcp_discovery_lock = threading.Lock()
|
||||
app.state._mcp_clients_lock = threading.Lock()
|
||||
app.state._mcp_clients = list(mcp_clients or [])
|
||||
app.state._managed_worker_lock = threading.Lock()
|
||||
app.state._managed_workers: set[threading.Thread] = set()
|
||||
app.state._managed_runtime_stopping = False
|
||||
app.state.session_start = time.time()
|
||||
# Exposed so WebSocket handlers can authenticate the handshake (the HTTP
|
||||
# AuthMiddleware never sees WS upgrade requests). Empty = auth disabled.
|
||||
app.state.api_key = api_key
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown_managed_runtime() -> None:
|
||||
# Quiesce every producer before touching the shared MCP pool. Route
|
||||
# workers are registered under this lock, so none can slip in after
|
||||
# the snapshot. The scheduler has a two-phase stop because closing an
|
||||
# MCP transport may be what releases an in-flight tick.
|
||||
with app.state._managed_worker_lock:
|
||||
app.state._managed_runtime_stopping = True
|
||||
managed_workers = list(app.state._managed_workers)
|
||||
|
||||
# Stop external listener threads before draining ticks or closing the
|
||||
# shared MCP pool. Channel callbacks are wired to that same pool by
|
||||
# ``serve`` and otherwise could race teardown or survive app restart.
|
||||
channel_bridge = getattr(app.state, "channel_bridge", None)
|
||||
disconnect_channels = getattr(channel_bridge, "disconnect", None)
|
||||
if callable(disconnect_channels):
|
||||
try:
|
||||
disconnect_channels()
|
||||
except Exception:
|
||||
logger.debug("Channel bridge shutdown failed", exc_info=True)
|
||||
|
||||
def _join_workers(timeout: float) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
for thread in managed_workers:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
thread.join(timeout=remaining)
|
||||
|
||||
scheduler = getattr(app.state, "agent_scheduler", None)
|
||||
scheduler_wait = None
|
||||
scheduler_drained = True
|
||||
if scheduler is not None:
|
||||
try:
|
||||
request_stop = getattr(scheduler, "request_stop", None)
|
||||
wait_stopped = getattr(scheduler, "wait_stopped", None)
|
||||
if callable(request_stop) and callable(wait_stopped):
|
||||
request_stop()
|
||||
scheduler_wait = wait_stopped
|
||||
scheduler_drained = bool(
|
||||
wait_stopped(timeout=_MANAGED_SHUTDOWN_GRACE_SECONDS)
|
||||
)
|
||||
else:
|
||||
scheduler.stop()
|
||||
scheduler_drained = not bool(
|
||||
getattr(scheduler, "is_running", False)
|
||||
)
|
||||
except Exception:
|
||||
scheduler_drained = False
|
||||
logger.debug("Agent scheduler shutdown failed", exc_info=True)
|
||||
|
||||
# Give normal work a brief chance to finish before cancellation.
|
||||
_join_workers(timeout=_MANAGED_SHUTDOWN_GRACE_SECONDS)
|
||||
with app.state._mcp_clients_lock:
|
||||
mcp_clients_to_close = list(app.state._mcp_clients)
|
||||
for client in mcp_clients_to_close:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
logger.debug("MCP client shutdown failed", exc_info=True)
|
||||
|
||||
# Transport closure interrupts blocked MCP reads. Drain the workers a
|
||||
# second time so shutdown does not return while they still own runtime
|
||||
# state. Any stragglers can no longer issue transport requests because
|
||||
# MCPClient marks itself closed before closing its transport.
|
||||
if scheduler_wait is not None:
|
||||
try:
|
||||
scheduler_drained = bool(
|
||||
scheduler_wait(timeout=_MANAGED_SHUTDOWN_DRAIN_SECONDS)
|
||||
)
|
||||
except Exception:
|
||||
scheduler_drained = False
|
||||
logger.debug("Agent scheduler drain failed", exc_info=True)
|
||||
_join_workers(timeout=_MANAGED_SHUTDOWN_DRAIN_SECONDS)
|
||||
alive = [thread.name for thread in managed_workers if thread.is_alive()]
|
||||
if alive:
|
||||
logger.warning("Managed workers did not stop during shutdown: %s", alive)
|
||||
|
||||
# A backend created by ``serve`` or lazily by a managed route belongs
|
||||
# to this app process. Close it only after every tracked consumer has
|
||||
# been drained; injected/borrowed backends remain the caller's concern.
|
||||
owned_memory_backend = None
|
||||
runtime_drained = scheduler_drained and not alive
|
||||
if runtime_drained:
|
||||
with app.state._memory_backend_lock:
|
||||
if app.state._owns_memory_backend:
|
||||
owned_memory_backend = app.state.memory_backend
|
||||
app.state.memory_backend = None
|
||||
app.state._owns_memory_backend = False
|
||||
else:
|
||||
# A live worker may itself hold _memory_backend_lock while opening
|
||||
# the backend. Respect the bounded shutdown deadline: do not wait
|
||||
# on that lock or mutate ownership until every consumer is gone.
|
||||
logger.warning(
|
||||
"Skipping memory backend cleanup because managed runtime "
|
||||
"consumers did not stop"
|
||||
)
|
||||
close_memory = getattr(owned_memory_backend, "close", None)
|
||||
if callable(close_memory):
|
||||
try:
|
||||
close_memory()
|
||||
except Exception:
|
||||
logger.debug("Memory backend shutdown failed", exc_info=True)
|
||||
|
||||
# Wire up trace store if traces are enabled.
|
||||
#
|
||||
# We deliberately do NOT subscribe the trace store to the bus. The chat
|
||||
|
||||
@@ -59,7 +59,6 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a new API key with ``oj_sk_`` prefix."""
|
||||
return f"oj_sk_{secrets.token_urlsafe(32)}"
|
||||
|
||||
@@ -215,8 +215,8 @@ COMPARISON_HTML = """\
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>OpenJarvis (Local)</th>
|
||||
<th>GPT-5.3</th>
|
||||
<th>Claude Opus 4.6</th>
|
||||
<th>GPT-5.6 Sol</th>
|
||||
<th>Claude Fable 5</th>
|
||||
<th>Gemini 3.1 Pro</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -261,11 +261,11 @@ COMPARISON_HTML = """\
|
||||
<div class="cc-value">$0.00/mo</div>
|
||||
</div>
|
||||
<div class="calc-card cloud">
|
||||
<div class="cc-label">GPT-5.3</div>
|
||||
<div class="cc-label">GPT-5.6 Sol</div>
|
||||
<div class="cc-value" id="calc-gpt">--</div>
|
||||
</div>
|
||||
<div class="calc-card cloud">
|
||||
<div class="cc-label">Claude Opus 4.6</div>
|
||||
<div class="cc-label">Claude Fable 5</div>
|
||||
<div class="cc-value" id="calc-claude">--</div>
|
||||
</div>
|
||||
<div class="calc-card cloud">
|
||||
@@ -292,13 +292,13 @@ COMPARISON_HTML = """\
|
||||
<script>
|
||||
// Embedded data -- avoids API calls, keeps the page static and fast.
|
||||
const CLOUD_PRICING = {
|
||||
"gpt-5.3": {
|
||||
input_per_1m: 2.00, output_per_1m: 10.00,
|
||||
label: "GPT-5.3"
|
||||
"gpt-5.6-sol": {
|
||||
input_per_1m: 5.00, output_per_1m: 30.00,
|
||||
label: "GPT-5.6 Sol"
|
||||
},
|
||||
"claude-opus-4.6": {
|
||||
input_per_1m: 5.00, output_per_1m: 25.00,
|
||||
label: "Claude Opus 4.6"
|
||||
"claude-fable-5": {
|
||||
input_per_1m: 10.00, output_per_1m: 50.00,
|
||||
label: "Claude Fable 5"
|
||||
},
|
||||
"gemini-3.1-pro": {
|
||||
input_per_1m: 2.00, output_per_1m: 12.00,
|
||||
@@ -376,8 +376,8 @@ function updateTable() {
|
||||
const sc = SCENARIOS[activeScenario];
|
||||
const i = sc.avg_input_tokens, o = sc.avg_output_tokens;
|
||||
const c = sc.calls_per_month;
|
||||
const gpt = calcMonthlyCost(c, i, o, 'gpt-5.3');
|
||||
const claude = calcMonthlyCost(c, i, o, 'claude-opus-4.6');
|
||||
const gpt = calcMonthlyCost(c, i, o, 'gpt-5.6-sol');
|
||||
const claude = calcMonthlyCost(c, i, o, 'claude-fable-5');
|
||||
const gemini = calcMonthlyCost(c, i, o, 'gemini-3.1-pro');
|
||||
|
||||
document.getElementById('t-gpt-m').textContent = fmtDollar(gpt);
|
||||
@@ -410,8 +410,8 @@ function updateCalc() {
|
||||
const avgOut = tpc - avgIn;
|
||||
const callsPerMonth = cpd * 30;
|
||||
|
||||
const gpt = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'gpt-5.3');
|
||||
const claude = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'claude-opus-4.6');
|
||||
const gpt = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'gpt-5.6-sol');
|
||||
const claude = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'claude-fable-5');
|
||||
const gemini = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'gemini-3.1-pro');
|
||||
|
||||
document.getElementById('calc-gpt').textContent = fmtDollar(gpt) + '/mo';
|
||||
|
||||
@@ -184,7 +184,7 @@ DASHBOARD_HTML = """\
|
||||
<div class="providers">
|
||||
<div class="provider-card openai">
|
||||
<div class="pname">OpenAI</div>
|
||||
<div class="pmodel">GPT-5.3 — $2.00 / $10.00 per 1M tokens</div>
|
||||
<div class="pmodel">GPT-5.6 Sol — $5.00 / $30.00 per 1M tokens</div>
|
||||
<div class="savings-amount" id="save-openai">$0.00</div>
|
||||
<div class="breakdown">
|
||||
<div class="item">
|
||||
@@ -199,7 +199,7 @@ DASHBOARD_HTML = """\
|
||||
</div>
|
||||
<div class="provider-card anthropic">
|
||||
<div class="pname">Anthropic</div>
|
||||
<div class="pmodel">Claude Opus 4.6 — $5.00 / $25.00 per 1M tokens</div>
|
||||
<div class="pmodel">Claude Fable 5 — $10.00 / $50.00 per 1M tokens</div>
|
||||
<div class="savings-amount" id="save-anthropic">$0.00</div>
|
||||
<div class="breakdown">
|
||||
<div class="item">
|
||||
@@ -281,12 +281,12 @@ DASHBOARD_HTML = """\
|
||||
<div class="providers-heading">Energy & Compute Avoided</div>
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card">
|
||||
<div class="mheading">Energy Saved (vs GPT-5.3)</div>
|
||||
<div class="mheading">Energy Saved (vs GPT-5.6 Sol)</div>
|
||||
<div class="mvalue green" id="energy-joules">0 <span class="munit">J</span></div>
|
||||
<div class="msub" id="energy-kwh">0 kWh of cloud datacenter energy avoided</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="mheading">FLOPs Avoided (vs GPT-5.3)</div>
|
||||
<div class="mheading">FLOPs Avoided (vs GPT-5.6 Sol)</div>
|
||||
<div class="mvalue purple" id="flops-val">0 <span class="munit">FLOP</span></div>
|
||||
<div class="msub" id="flops-sub">cloud compute operations not needed</div>
|
||||
</div>
|
||||
@@ -354,8 +354,8 @@ async function refresh() {
|
||||
providerMap[p.provider] = p;
|
||||
});
|
||||
|
||||
// OpenAI / GPT-5.3
|
||||
const oa = providerMap['gpt-5.3'] || {};
|
||||
// OpenAI / GPT-5.6 Sol
|
||||
const oa = providerMap['gpt-5.6-sol'] || {};
|
||||
document.getElementById('save-openai')
|
||||
.textContent = fmtDollar(oa.total_cost || 0);
|
||||
document.getElementById('save-openai-in')
|
||||
@@ -363,8 +363,8 @@ async function refresh() {
|
||||
document.getElementById('save-openai-out')
|
||||
.textContent = fmtDollar(oa.output_cost || 0);
|
||||
|
||||
// Anthropic / Claude Opus 4.6
|
||||
const an = providerMap['claude-opus-4.6'] || {};
|
||||
// Anthropic / Claude Fable 5
|
||||
const an = providerMap['claude-fable-5'] || {};
|
||||
document.getElementById('save-anthropic')
|
||||
.textContent = fmtDollar(an.total_cost || 0);
|
||||
document.getElementById('save-anthropic-in')
|
||||
@@ -384,13 +384,13 @@ async function refresh() {
|
||||
// Monthly projections
|
||||
const proj = d.monthly_projection || {};
|
||||
document.getElementById('proj-openai')
|
||||
.textContent = fmtDollar(proj['gpt-5.3'] || 0);
|
||||
.textContent = fmtDollar(proj['gpt-5.6-sol'] || 0);
|
||||
document.getElementById('proj-anthropic')
|
||||
.textContent = fmtDollar(proj['claude-opus-4.6'] || 0);
|
||||
.textContent = fmtDollar(proj['claude-fable-5'] || 0);
|
||||
document.getElementById('proj-google')
|
||||
.textContent = fmtDollar(proj['gemini-3.1-pro'] || 0);
|
||||
|
||||
// Energy / FLOPs (use GPT-5.3 as reference)
|
||||
// Energy / FLOPs (use GPT-5.6 Sol as reference)
|
||||
const ej = oa.energy_joules || 0;
|
||||
const eWh = oa.energy_wh || 0;
|
||||
const fl = oa.flops || 0;
|
||||
|
||||
@@ -238,6 +238,7 @@ class _LiveGPUSampler:
|
||||
try:
|
||||
# Suppress legacy pynvml deprecation FutureWarning (#389).
|
||||
import warnings as _warnings
|
||||
|
||||
with _warnings.catch_warnings():
|
||||
_warnings.filterwarnings(
|
||||
"ignore",
|
||||
@@ -248,9 +249,7 @@ class _LiveGPUSampler:
|
||||
|
||||
pynvml.nvmlInit()
|
||||
count = pynvml.nvmlDeviceGetCount()
|
||||
self._handles = [
|
||||
pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(count)
|
||||
]
|
||||
self._handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(count)]
|
||||
self._pynvml = pynvml
|
||||
self._available = bool(self._handles)
|
||||
if not self._available:
|
||||
@@ -528,9 +527,7 @@ async def _stream_research(
|
||||
for piece in _chunk_synthesis(final_answer or ""):
|
||||
yield _sse({"type": "synthesis", "text": piece})
|
||||
if final_sources:
|
||||
yield _sse(
|
||||
{"type": "final_sources", "sources": final_sources}
|
||||
)
|
||||
yield _sse({"type": "final_sources", "sources": final_sources})
|
||||
continue
|
||||
|
||||
yield _sse(event)
|
||||
@@ -539,9 +536,7 @@ async def _stream_research(
|
||||
# client still gets the error frame (emitted above) followed by done.
|
||||
# The done frame also carries the deduped sources so a client that
|
||||
# only listens for ``done`` still gets the canonical citation list.
|
||||
yield _sse(
|
||||
{"type": "done", "usage": final_usage, "sources": final_sources}
|
||||
)
|
||||
yield _sse({"type": "done", "usage": final_usage, "sources": final_sources})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Consumer loop crashed unexpectedly (e.g. JSON serialization fault,
|
||||
# logic bug). Surface a clean error frame rather than letting the
|
||||
@@ -553,9 +548,7 @@ async def _stream_research(
|
||||
"message": f"Research failed: {type(exc).__name__}: {exc}",
|
||||
}
|
||||
)
|
||||
yield _sse(
|
||||
{"type": "done", "usage": final_usage, "sources": final_sources}
|
||||
)
|
||||
yield _sse({"type": "done", "usage": final_usage, "sources": final_sources})
|
||||
finally:
|
||||
# The worker may still be cleaning up (rarely) — make sure we don't
|
||||
# leak a dangling task. Swallow any straggler exception so a worker
|
||||
|
||||
@@ -59,23 +59,34 @@ def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message
|
||||
If any message already carries a system role, the caller has supplied
|
||||
their own grounding and we leave the list untouched (no double-prompting).
|
||||
|
||||
Resolution of the identity text: ``app_config.agent.default_system_prompt``
|
||||
when a config is wired onto ``app.state``; otherwise fall back to
|
||||
``load_config()``. Config resolution is wrapped so a broken/missing
|
||||
config degrades to "no injection" rather than crashing the endpoint, but
|
||||
the failure is logged (per REVIEW.md — never silently swallow).
|
||||
Resolution of the identity text: the config comes from ``app.state`` when
|
||||
wired, otherwise ``load_config()``; the prompt itself is assembled by
|
||||
``SystemPromptBuilder`` from ``agent.default_system_prompt`` plus the
|
||||
persona files (SOUL.md/MEMORY.md/USER.md), matching
|
||||
``_build_managed_system_prompt`` in ``agent_manager_routes.py``. Config
|
||||
resolution is wrapped so a broken/missing config degrades to "no
|
||||
injection" rather than crashing the endpoint, but the failure is logged
|
||||
(per REVIEW.md — never silently swallow).
|
||||
"""
|
||||
if any(m.role == Role.SYSTEM for m in messages):
|
||||
return messages
|
||||
|
||||
prompt = ""
|
||||
try:
|
||||
if app_config is not None:
|
||||
prompt = app_config.agent.default_system_prompt or ""
|
||||
else:
|
||||
cfg = app_config
|
||||
if cfg is None:
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
prompt = load_config().agent.default_system_prompt or ""
|
||||
cfg = load_config()
|
||||
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
builder = SystemPromptBuilder(
|
||||
agent_template=cfg.agent.default_system_prompt or "",
|
||||
memory_files_config=getattr(cfg, "memory_files", None),
|
||||
system_prompt_config=getattr(cfg, "system_prompt", None),
|
||||
)
|
||||
prompt = builder.build()
|
||||
except Exception:
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Identity system prompt resolution failed; "
|
||||
@@ -231,8 +242,13 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
# tools (e.g. injecting MCP tools through this endpoint and wanting
|
||||
# the agent to execute them), add an explicit opt-in header rather
|
||||
# than removing this guard — silent re-routing is what produced #414.
|
||||
# ``_handle_agent`` (sync ``agent.run()``) and ``_handle_direct`` (sync
|
||||
# ``engine.generate()``) both make blocking upstream calls; run them in a
|
||||
# worker thread so a slow/wedged non-streaming request can't stall the
|
||||
# event loop and every other concurrent request with it.
|
||||
if agent is not None and not request_body.tools:
|
||||
response = _handle_agent(
|
||||
response = await asyncio.to_thread(
|
||||
_handle_agent,
|
||||
agent,
|
||||
model,
|
||||
request_body,
|
||||
@@ -242,7 +258,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
)
|
||||
else:
|
||||
bus = getattr(request.app.state, "bus", None)
|
||||
response = _handle_direct(
|
||||
response = await asyncio.to_thread(
|
||||
_handle_direct,
|
||||
engine,
|
||||
model,
|
||||
request_body,
|
||||
@@ -319,6 +336,34 @@ def _remember_exchange(
|
||||
)
|
||||
|
||||
|
||||
def _engine_key_for_model(engine: Any, model: str) -> str | None:
|
||||
"""Resolve the engine that advertised *model* through wrapper layers."""
|
||||
from openjarvis.engine.multi import MultiEngine
|
||||
from openjarvis.security.guardrails import GuardrailsEngine
|
||||
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
|
||||
|
||||
current = engine
|
||||
while current is not None:
|
||||
if isinstance(current, MultiEngine):
|
||||
return current.engine_key_for(model)
|
||||
if isinstance(current, InstrumentedEngine):
|
||||
current = current._inner
|
||||
continue
|
||||
if isinstance(current, GuardrailsEngine):
|
||||
current = current._engine
|
||||
continue
|
||||
engine_id = getattr(current, "engine_id", None)
|
||||
return engine_id if isinstance(engine_id, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def _uses_direct_cloud_router(engine: Any, model: str) -> bool:
|
||||
"""Whether *model* should bypass the configured engine for direct cloud."""
|
||||
from openjarvis.server.cloud_router import is_cloud_model
|
||||
|
||||
return is_cloud_model(model) and _engine_key_for_model(engine, model) != "litellm"
|
||||
|
||||
|
||||
def _handle_direct(
|
||||
engine,
|
||||
model: str,
|
||||
@@ -524,12 +569,13 @@ async def _handle_stream_tools(
|
||||
tool_calls) — identical to the prior plain-stream behaviour, so this never
|
||||
regresses non-tool-capable engines.
|
||||
"""
|
||||
from openjarvis.server.cloud_router import is_cloud_model
|
||||
|
||||
messages = _to_messages(req.messages)
|
||||
messages = _ensure_identity_prompt(messages, app_config)
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
use_cloud = is_cloud_model(model)
|
||||
use_cloud = _uses_direct_cloud_router(engine, model)
|
||||
telemetry_engine = (
|
||||
"cloud" if use_cloud else (_engine_key_for_model(engine, model) or "ollama")
|
||||
)
|
||||
query_text = ""
|
||||
for _m in reversed(req.messages):
|
||||
if _m.role == "user" and _m.content:
|
||||
@@ -609,7 +655,7 @@ async def _handle_stream_tools(
|
||||
# Tag the finish chunk with the engine label, matching _handle_stream
|
||||
# so UI/telemetry consumers see the same field on the tools path.
|
||||
finish_dict.setdefault("telemetry", {})
|
||||
finish_dict["telemetry"]["engine"] = "cloud" if use_cloud else "ollama"
|
||||
finish_dict["telemetry"]["engine"] = telemetry_engine
|
||||
if complexity_info is not None:
|
||||
finish_dict["complexity"] = complexity_info.model_dump()
|
||||
yield f"data: {_json.dumps(finish_dict)}\n\n"
|
||||
@@ -651,11 +697,7 @@ async def _handle_stream(
|
||||
"""
|
||||
import time
|
||||
|
||||
from openjarvis.server.cloud_router import (
|
||||
is_cloud_model,
|
||||
stream_cloud,
|
||||
stream_local,
|
||||
)
|
||||
from openjarvis.server.cloud_router import stream_cloud, stream_local
|
||||
|
||||
messages = _to_messages(req.messages)
|
||||
messages = _ensure_identity_prompt(messages, app_config)
|
||||
@@ -670,7 +712,10 @@ async def _handle_stream(
|
||||
|
||||
# Route directly to the right backend — bypasses engine routing entirely
|
||||
# so broken MultiEngine state can never misdirect requests.
|
||||
use_cloud = is_cloud_model(model)
|
||||
use_cloud = _uses_direct_cloud_router(engine, model)
|
||||
telemetry_engine = (
|
||||
"cloud" if use_cloud else (_engine_key_for_model(engine, model) or "ollama")
|
||||
)
|
||||
|
||||
async def generate():
|
||||
started_at = time.time()
|
||||
@@ -775,7 +820,7 @@ async def _handle_stream(
|
||||
query=query_text,
|
||||
result=full_content,
|
||||
model=model,
|
||||
engine="cloud" if use_cloud else "ollama",
|
||||
engine=telemetry_engine,
|
||||
started_at=started_at,
|
||||
ended_at=time.time(),
|
||||
)
|
||||
@@ -808,7 +853,7 @@ async def _handle_stream(
|
||||
# We use the routing decision (use_cloud) directly rather than
|
||||
# unwrapping the engine chain, which can be in a broken state.
|
||||
finish_dict.setdefault("telemetry", {})
|
||||
finish_dict["telemetry"]["engine"] = "cloud" if use_cloud else "ollama"
|
||||
finish_dict["telemetry"]["engine"] = telemetry_engine
|
||||
|
||||
if complexity_info is not None:
|
||||
finish_dict["complexity"] = complexity_info.model_dump()
|
||||
@@ -825,24 +870,40 @@ async def _handle_stream(
|
||||
|
||||
@router.get("/v1/models")
|
||||
async def list_models(request: Request) -> ModelListResponse:
|
||||
"""List locally installed models (Ollama).
|
||||
"""List selectable engine models for the installed-model picker.
|
||||
|
||||
Cloud models are not included here — they live in the Cloud Models tab
|
||||
of the UI and are selected there, not from this endpoint.
|
||||
Direct cloud models live in the Cloud Models tab. Models advertised by a
|
||||
configured LiteLLM engine remain here because LiteLLM owns their routing
|
||||
and may use provider-qualified IDs that resemble OpenRouter IDs.
|
||||
"""
|
||||
from openjarvis.server.cloud_router import is_cloud_model, list_local_models
|
||||
|
||||
# Prefer engine.list_models() so mock engines work in tests.
|
||||
# Filter out any cloud model IDs that may appear via MultiEngine.
|
||||
# Filter out direct-cloud model IDs that may appear via MultiEngine, but
|
||||
# retain provider-qualified IDs owned by the configured LiteLLM engine.
|
||||
# Fall back to direct Ollama query only when the engine returns nothing.
|
||||
engine = request.app.state.engine
|
||||
all_ids = await asyncio.to_thread(engine.list_models)
|
||||
model_ids = [m for m in all_ids if not is_cloud_model(m)]
|
||||
model_ids = [
|
||||
m
|
||||
for m in all_ids
|
||||
if not is_cloud_model(m) or _engine_key_for_model(engine, m) == "litellm"
|
||||
]
|
||||
if not model_ids:
|
||||
model_ids = await list_local_models()
|
||||
|
||||
return ModelListResponse(
|
||||
data=[ModelObject(id=mid) for mid in model_ids],
|
||||
data=[
|
||||
ModelObject(
|
||||
id=mid,
|
||||
owned_by=(
|
||||
"litellm"
|
||||
if _engine_key_for_model(engine, mid) == "litellm"
|
||||
else "openjarvis"
|
||||
),
|
||||
)
|
||||
for mid in model_ids
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,19 +23,19 @@ from openjarvis.core.types import TOKEN_COUNTING_VERSION # noqa: E402,F401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLOUD_PRICING: Dict[str, Dict[str, float]] = {
|
||||
"gpt-5.3": {
|
||||
"input_per_1m": 2.00,
|
||||
"output_per_1m": 10.00,
|
||||
"label": "GPT-5.3",
|
||||
"gpt-5.6-sol": {
|
||||
"input_per_1m": 5.00,
|
||||
"output_per_1m": 30.00,
|
||||
"label": "GPT-5.6 Sol",
|
||||
"provider": "OpenAI",
|
||||
"params_b": 200.0,
|
||||
"energy_wh_per_1k_tokens": 0.4,
|
||||
"flops_per_token": 3.0e12,
|
||||
},
|
||||
"claude-opus-4.6": {
|
||||
"input_per_1m": 5.00,
|
||||
"output_per_1m": 25.00,
|
||||
"label": "Claude Opus 4.6",
|
||||
"claude-fable-5": {
|
||||
"input_per_1m": 10.00,
|
||||
"output_per_1m": 50.00,
|
||||
"label": "Claude Fable 5",
|
||||
"provider": "Anthropic",
|
||||
"params_b": 137.0,
|
||||
"energy_wh_per_1k_tokens": 0.5,
|
||||
|
||||
@@ -25,9 +25,11 @@ class SessionStore:
|
||||
def __init__(self, db_path: str = "") -> None:
|
||||
if not db_path:
|
||||
db_path = str(get_config_dir() / "sessions.db")
|
||||
from openjarvis.security.file_utils import secure_create
|
||||
# Ensure the parent directory exists (skip for :memory:)
|
||||
if db_path != ":memory:":
|
||||
from openjarvis.security.file_utils import secure_create
|
||||
|
||||
secure_create(Path(db_path))
|
||||
secure_create(Path(db_path))
|
||||
self._db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._db.row_factory = sqlite3.Row
|
||||
self._create_tables()
|
||||
|
||||
@@ -16,6 +16,7 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, BaseAgent
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.engine._base import looks_like_context_length_error
|
||||
from openjarvis.server.models import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionRequest,
|
||||
@@ -194,8 +195,10 @@ class AgentStreamBridge:
|
||||
logger.error("Agent stream error: %s", exc, exc_info=True)
|
||||
|
||||
error_str = str(exc)
|
||||
if "context length" in error_str.lower() or (
|
||||
"400" in error_str and "too long" in error_str.lower()
|
||||
if (
|
||||
getattr(exc, "is_context_length_error", False)
|
||||
or looks_like_context_length_error(error_str)
|
||||
or ("400" in error_str and "too long" in error_str.lower())
|
||||
):
|
||||
error_content = (
|
||||
"The input is too long for the model's context window. "
|
||||
|
||||
@@ -116,13 +116,9 @@ def create_webhook_router(
|
||||
# Fail closed: an unconfigured token means we cannot verify the sender,
|
||||
# so reject rather than trust unsigned input.
|
||||
if not twilio_auth_token:
|
||||
logger.error(
|
||||
"Twilio webhook rejected: TWILIO_AUTH_TOKEN not configured."
|
||||
)
|
||||
logger.error("Twilio webhook rejected: TWILIO_AUTH_TOKEN not configured.")
|
||||
return Response("Webhook signature verification not configured", 403)
|
||||
if not _validate_twilio_signature(
|
||||
twilio_auth_token, url, params, signature
|
||||
):
|
||||
if not _validate_twilio_signature(twilio_auth_token, url, params, signature):
|
||||
return Response("Invalid signature", status_code=403)
|
||||
|
||||
from_number = params.get("From", "")
|
||||
@@ -266,9 +262,7 @@ def create_webhook_router(
|
||||
auth = request.headers.get("Authorization", "")
|
||||
# Fail closed when no password is configured.
|
||||
if not bluebubbles_password:
|
||||
logger.error(
|
||||
"BlueBubbles webhook rejected: password not configured."
|
||||
)
|
||||
logger.error("BlueBubbles webhook rejected: password not configured.")
|
||||
return Response("Webhook authentication not configured", 403)
|
||||
if not hmac.compare_digest(auth, bluebubbles_password):
|
||||
return Response("Invalid password", status_code=403)
|
||||
@@ -321,9 +315,7 @@ def create_webhook_router(
|
||||
|
||||
# Fail closed: reject when no app secret is configured to verify HMAC.
|
||||
if not whatsapp_app_secret:
|
||||
logger.error(
|
||||
"WhatsApp webhook rejected: app secret not configured."
|
||||
)
|
||||
logger.error("WhatsApp webhook rejected: app secret not configured.")
|
||||
return Response("Webhook signature verification not configured", 403)
|
||||
signature = request.headers.get("X-Hub-Signature-256", "")
|
||||
expected = (
|
||||
@@ -373,9 +365,7 @@ def create_webhook_router(
|
||||
# Fail closed: require a configured channel + webhook secret to verify
|
||||
# the sender before processing any inbound message.
|
||||
if sb is None or not getattr(sb, "webhook_secret", ""):
|
||||
logger.error(
|
||||
"SendBlue webhook rejected: webhook_secret not configured."
|
||||
)
|
||||
logger.error("SendBlue webhook rejected: webhook_secret not configured.")
|
||||
return Response("Webhook secret not configured", status_code=403)
|
||||
header_secret = request.headers.get("x-sendblue-secret", "")
|
||||
if not hmac.compare_digest(header_secret, sb.webhook_secret):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user