The real `jarvis ask --agent opencode` path passes an InstrumentedEngine
(telemetry wrapper) whose underlying engine — and its `_host` — lives at
`_inner`. The base-URL derivation only checked the top object, so it returned
"", no provider was registered, and opencode 500'd on `openjarvis/<model>`.
Direct construction with a raw engine masked this; only the CLI path exposed
it.
- _derive_openai_base_url now unwraps up to 6 wrapper layers
(`_inner`/`_engine`/`_wrapped`) to find `_host`/`base_url`.
- run() resolves the provider/model spec up front and, when it genuinely
can't (no base URL + bare model name), returns a clear actionable error
instead of letting opencode 500.
Tests: wrapper-unwrap derivation + unresolvable-provider guard. 20 passed,
ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two hardening fixes for headless use, found while verifying real runs:
- Permissions: opencode's interactive default *asks* before some actions (and
`plan` asks before bash), which would block forever with no TTY. The agent
now writes an explicit permission policy: `build` allows edit+bash, `plan`
denies them (read-only), overridable via a `permission` kwarg. Verified: a
bash task completes without hanging and plan mode refuses to create files.
- Config no longer written into the user's workspace. We now write provider +
permission config to a private temp file referenced via `OPENCODE_CONFIG`
(confirmed honored by opencode), keeping the workspace clean while opencode
still operates there as cwd.
Tests updated to cover `_build_config` (provider presence, per-mode
permission, custom override, no-pollution). 18 passed, ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A real-model E2E (Ollama qwen3:8b) exposed what unit tests with synthetic
parts missed: `POST /session/{id}/message` returns only the final assistant
message (text/reasoning), while ToolParts live in intermediate assistant
messages. The parser was reading the wrong message, so tool_results was always
empty even when opencode actually edited files.
- run(): after the prompt POST, GET /session/{id}/message and collect parts
across the whole turn for tool extraction (content still comes from the
final message). Verified against a live opencode session.
- _extract_tool_results: success now keys on opencode's state.status ==
"completed" (states: completed | error | running | pending).
- Test now feeds the real full-turn message shape and asserts the write tool
is recovered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an `OpenCodeAgent` (registry key `opencode`) that delegates coding tasks
to opencode (https://opencode.ai, MIT) while keeping inference local-first:
OpenJarvis's engine backs opencode via an OpenAI-compatible provider.
How it works:
- Derives an OpenAI-compatible base URL from the engine (e.g. Ollama/vLLM at
`<host>/v1`) and writes an `opencode.json` registering it as an
`@ai-sdk/openai-compatible` provider (`openjarvis/<model>`).
- Spawns a headless `opencode serve` (loopback, random port), waits for
`/global/health`, then drives a session: `POST /session` →
`POST /session/{id}/message` with `model={providerID,modelID}` + agent
(`build`/`plan`) → parses message `parts` (text → content, tool → tool_results)
into an `AgentResult`. `close()` disposes the server.
- opencode is an external binary (not bundled); `run()` returns a clear,
actionable error when it's missing, mirroring ClaudeCodeAgent's degradation.
Verified end-to-end against the real opencode binary wired to a stub
OpenAI-compatible engine: opencode called the local endpoint and the agent
parsed the response (content/finish/model) correctly. Unit tests cover part
parsing, base-URL derivation, provider-config writing (incl. merge), binary
detection, graceful degradation, and run() parsing with a mocked client — 15
passed, ruff clean. Registered via the standard try/except import in
agents/__init__.py; documented in docs/user-guide/agents.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SystemPromptBuilder (which loads the SOUL/MEMORY/USER persona files) was wired
only into the one-shot `jarvis ask` path, so persistent agents run through
AgentExecutor ignored persona entirely. The naive "pass prompt_builder" fix is
insufficient: `prompt_builder` was dropped at every __init__ hop
(ToolUsingAgent never accepted/forwarded it), and monitor_operative/operative
assemble their own system prompt and never consult `_prompt_builder` — so they
would silently ignore it even if it arrived.
Fix:
- `SystemPromptBuilder.persona_sections()` returns just the SOUL/MEMORY/USER
sections (no agent template), for agents that build their own prompt and want
to *append* persona rather than have it replace their instructions.
- `BaseAgent._apply_persona()` appends persona to a self-assembled prompt
(no-op without a builder or persona files).
- Thread `prompt_builder` through the __init__ chain: ToolUsingAgent now
accepts and forwards it to BaseAgent; monitor_operative and operative forward
it and call `_apply_persona()` on their assembled system prompt.
- AgentExecutor constructs a SystemPromptBuilder from config and passes it to
any agent whose __init__ accepts it (same gating as session_store /
memory_backend). Agents that override __init__ without forwarding (e.g.
orchestrator) opt out automatically and keep their own machinery.
Specialized prompts are preserved — persona is appended, not substituted. The
one-shot path is unchanged.
Verified: persona_sections() excludes the template but build() still includes
both; monitor_operative/operative receive the builder through the chain and
their assembled prompt includes the SOUL content. New tests in
tests/agents/test_persona_persistent.py (11 passed; ruff clean).
Closes#376
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`_stream_managed_agent` had diverged from the canonical cli/ask.py path and
lost three behaviours. All three are fixed via small extracted, unit-tested
helpers:
- #382: cross-request history replay dropped stored `tool_calls`, so the model
never saw its own prior tool use and fabricated tool output on turn 2+.
`_replay_history_messages` now reconstructs the assistant tool-use message
plus matching tool-result messages (synthesised, consistent tool_call_ids).
- #386: only temperature/max_tokens reached the engine. `_sampler_kwargs`
forwards repetition_penalty / top_p / top_k / min_p / frequency_penalty /
presence_penalty when set in the agent config (opt-in; default agents send
nothing extra). Fixes degenerate repetition loops on local models with no
repetition_penalty.
- #395: tools were built with a bare `tool_cls()`, so memory_* / channel_* /
llm tools loaded with no backend and failed on every call.
`_instantiate_managed_tool` injects backend / channel / engine the same way
cli/ask.py::_build_tools does.
Verified empirically: replay emits user → assistant(tool_calls) → tool(result)
with matching ids; sampler extraction forwards only set keys; DI gives memory
tools a backend and llm the engine/model. New tests in
tests/server/test_managed_agent_streaming.py (helpers are pure, so verifiable
without a live engine). 38 passed locally incl. existing route tests.
Closes#382Closes#386Closes#395
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All three deployment methods bound 0.0.0.0:8000 with no API key, so following
the README produced a server reachable from any device on the network with no
auth. `check_bind_safety` already refuses to start a non-loopback bind without
a key (so these configs actually failed to start) — this wires the key in so
the documented path yields a *working, authenticated* server.
- docker-compose.yml: require `OPENJARVIS_API_KEY` via `${VAR:?...}` so
`docker compose up` fails fast when unset; added `deploy/docker/.env.example`
(un-ignored in .gitignore).
- systemd: add `EnvironmentFile=/etc/openjarvis/env` (no `-` prefix, so a
missing key file blocks startup rather than exposing an open server).
- launchd: bind `127.0.0.1` by default (the personal-device default — no
network exposure, no key needed) with a documented, commented opt-in to
0.0.0.0 + `OPENJARVIS_API_KEY`. Avoids shipping a usable default credential.
- Docs (docker/systemd/launchd) updated with the key-setup step.
- Tests assert each config can't reintroduce an open server, plus
`check_bind_safety` behavior across loopback/public × key/no-key.
Closes#221
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`AuthMiddleware` is a BaseHTTPMiddleware and never intercepts WebSocket
upgrade requests, so `/v1/chat/stream` and `/v1/agents/events` accepted any
connection — leaking all agent events/message content and allowing
unauthenticated inference even when an API key was configured for HTTP. The
A2A JSON-RPC server likewise dispatched every request without auth.
- Add `websocket_authorized(websocket, expected_key)` (constant-time compare)
and check it in both WS handlers BEFORE `accept()`, closing with code 1008
on failure. Token is read from `?token=` (browsers can't set WS headers) or
an `Authorization: Bearer` header. `create_app` now exposes the key via
`app.state.api_key`; when empty, auth is disabled, matching the HTTP
middleware's local-default behavior (so loopback dev is unchanged).
- A2AServer gains an optional `auth_token`: `handle_request(token=...)`
rejects with JSON-RPC -32001 before dispatch when configured, advertises
`{"schemes": ["bearer"]}` on the agent card, and stays open when unset.
Added `A2AConfig.auth_token`.
Verified empirically against the real mounted endpoints via TestClient: no
token / wrong token are rejected at the handshake (WebSocketDisconnect),
correct token streams normally, and no-key configs still connect freely.
Closes#217
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`python`-action tool templates evaluated expressions with `eval()` under a
restricted `__builtins__`. That sandbox is escapable via attribute walks like
`str.__class__.__mro__[-1].__subclasses__()`, reaching `object.__subclasses__()`
and arbitrary code. `shell`-action templates interpolated parameters into a
string and ran it with `shell=True`, so a value like `; rm -rf ~` or
`$(curl evil)` executed in the host shell.
Fixes:
- Replace `eval()` with a small AST interpreter (`safe_eval_expr`) that
implements an explicit node allowlist — literals, names, arithmetic/boolean/
comparison ops, ternaries, subscripts, container literals, and calls to a
fixed set of builtins only. Attribute access, lambdas, comprehensions, and
dunder names have no implementation and raise `ValueError`, so the escape
vectors are unreachable by construction. No `eval`/`exec` remains.
- Shell action: tokenize the FIXED template with `shlex.split` first, then
substitute params into individual argv elements and run with `shell=False`.
Injected metacharacters become inert literal arguments.
All shipped builtin templates (`str(float(value))`, `str(input) if input
else ...`, etc.) continue to work. Verified empirically: every known escape
payload is rejected and a `; touch <marker>` / `$(touch <marker>)` /
backtick injection never creates the marker file.
Closes#216
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on the cherry-picks from @Dilligaf371's #340 and
@bootcrowns's #202. Both fail ruff's E501 (88-char line limit) on
main:
- ``src/openjarvis/agents/executor.py:325`` (from #340) — the
``self._system is not None and getattr(..., "tool_executor", None)
is not None`` guard was 101 chars. Wrapped the condition.
- ``src/openjarvis/telemetry/gpu_monitor.py:55-60`` (from #202) —
five new GPU_SPECS entries (Jetson Orin NX 16GB/8GB, AGX Orin,
Snapdragon X Elite/Plus) were 90-93 chars. Wrapped each
GpuHardwareSpec constructor call onto its own block.
- ``tests/telemetry/test_gpu_monitor.py`` (from #202) — removed a
spurious blank line between imports and the first ``#`` comment
block, picked up by ``ruff check --fix`` (rule I001).
No behavior change — pure formatting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands 5 high-priority contributor PRs as one bundle, with original authorship preserved on every commit, plus follow-up commits from me to make each PR CI-green and non-regressive. See PR #362 for the full per-author commit breakdown and credits.
Credits: @tomaioo (#235), @Dilligaf371 (#339), @TX-Huang (#293, #294, #295).
Follow-up on @TX-Huang's PR #295. The new
test_soul_md_content_reaches_engine_in_simple_agent test has one
line at 92 chars that trips ruff's E501 (88 char limit). Wrap the
ternary onto multiple lines so the file passes ruff check on CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`SystemPromptBuilder` is fully implemented and tested in
`openjarvis.prompt.builder`, and `BaseAgent.__init__` accepts a
`prompt_builder` kwarg. But no production code path ever instantiates
the builder, so the persona-files feature documented in
`MemoryFilesConfig` (SOUL.md / MEMORY.md / USER.md) had no effect.
Users could write a fully-customized `~/.openjarvis/SOUL.md` and the
file was never read.
This PR wires it up in `_run_agent` (called by `jarvis ask --agent
<name>` and the new fallback from #294):
```python
if "prompt_builder" in inspect.signature(agent_cls.__init__).parameters:
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
agent_template=config.agent.default_system_prompt or "",
memory_files_config=config.memory_files,
system_prompt_config=config.system_prompt,
)
```
The `inspect` guard means agents that override `__init__` without
forwarding `prompt_builder` (e.g. OrchestratorAgent, which has its
own tool-aware system prompt) opt out automatically and keep
working unchanged. SimpleAgent and any future agent that inherits
`BaseAgent.__init__` directly picks up the persona files.
Also fixes a latent bug in `SystemPromptBuilder._load_file`: it
called `path.read_text()` with no encoding, which on Windows falls
back to the system code page (cp950 / cp932 / cp949) and raises
`UnicodeDecodeError` on any non-ASCII persona content. Pin to UTF-8.
## Tests
- Add `test_soul_md_content_reaches_engine_in_simple_agent` — writes
sentinel SOUL.md / MEMORY.md / USER.md to tmp_path, runs the
command, and asserts each sentinel appears in the SYSTEM message
passed to engine.generate.
- Add `test_orchestrator_keeps_its_own_system_prompt` — exercises
the `inspect`-based opt-out so OrchestratorAgent doesn't crash
on the unexpected kwarg.
Run: `pytest tests/cli/test_ask_agent.py tests/cli/test_ask_router.py
tests/cli/test_ask_e2e.py tests/agents/ tests/prompt/`
The 6 remaining failures (test_base_agent, test_loop_guard,
test_manager, test_native_openhands) are pre-existing on
origin/main and unrelated to this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on @TX-Huang's PR #294. The default-agent fallback now
routes `jarvis ask "..."` (no --agent) through `config.agent.default_agent`,
which dataclass-defaults to `"simple"`. The autouse `_clean_registries`
fixture in tests/conftest.py clears AgentRegistry between tests, so
the fallback then fails with `Unknown agent: simple` and every
CLI-driven test_energy_wiring test exits with code 1.
These tests exercise engine-level instrumentation, not agent dispatch
— ``test_engine_wrapped_with_instrumented``, the energy-monitor
lifecycle tests, and the end-to-end pipeline tests all care that the
engine gets wrapped and telemetry lands in SQLite, regardless of
whether the call goes through an agent. Set ``cfg.agent.default_agent
= ""`` in ``_energy_config`` to keep these tests on the direct-engine
path they were originally designed for. The dedicated
``test_agent_mode_uses_instrumented_engine`` (which explicitly passes
``--agent``) is unaffected.
Same pattern PR #294 already uses in tests/cli/test_ask_router.py
for the two MagicMock-config tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`jarvis ask "..."` (no `--agent` flag) routed straight to
`engine.generate()` regardless of `agent.default_agent` in the user's
config. As a result the persona stack — `agent.default_system_prompt`,
SOUL.md, MEMORY.md, USER.md — was silently bypassed for the most
common command.
Behavior now:
- `--agent X` → use agent X (unchanged)
- `--agent ""` (empty) → explicit opt-out, direct-to-engine mode
- (omitted) → fall back to `config.agent.default_agent`,
which dataclass-defaults to `"simple"`,
so persona settings finally take effect
Tests:
- Rename `test_no_agent_uses_direct_mode` to
`test_no_agent_flag_falls_back_to_config_default_agent` and update
its docstring to document the new behavior.
- Add `test_explicit_empty_agent_opts_out_of_agent_mode`.
- Add `test_no_agent_with_blank_config_default_uses_direct_mode` to
cover the case where the user clears `default_agent`.
- Update `_patch_engine` (test_ask_router) and `_patch_ask`
(test_ask_e2e) to re-register `SimpleAgent` after the autouse
`_clean_registries` conftest fixture, since the agent path now
runs in tests that previously short-circuited to direct mode.
- Add explicit `cfg.agent.default_agent = ""` to two
`test_ask_router` tests that mock load_config with a MagicMock
(so `cfg.agent.default_agent` doesn't auto-create as a truthy mock).
Note: `tests/cli/test_ask_context.py` has 2 unrelated pre-existing
failures on origin/main (memory backend returns None on Windows);
those are out of scope for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On Windows the default Python stdout encoding follows the system
ANSI code page (cp950 for zh-TW, cp932 for ja, cp949 for ko).
`click.echo()` then raises `UnicodeEncodeError` whenever a CJK
character lands in CLI output — `jarvis ask` returning Chinese
crashes with `'cp950' codec can't encode character '义'`.
Reconfigure `sys.stdout` and `sys.stderr` to UTF-8 with
`errors='replace'` at the `main()` entry point. Scoped to
`win32` so other platforms are untouched. Two unit tests verify
the reconfigure happens on Windows and doesn't on Linux.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`_total_ram_gb()` had branches for Darwin (sysctl) and Linux
(/proc/meminfo) but no Windows path, so `jarvis init` reported
"0.0 GB RAM" on every Windows host. The downstream
`recommend_model()` then fell back to VRAM-only sizing, often
selecting a smaller tier than the system can actually run.
Add a Windows branch that calls `GlobalMemoryStatusEx` via
ctypes — no new dependency. Add a platform-skip-aware test for
each OS branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on @Dilligaf371's PR #339 fix. Adds a regression test that
spawns a subprocess which consumes stdin without ever writing to
stdout, then issues `send_notification` from a worker thread. If the
override is missing, the thread blocks forever on `proc.stdout.readline()`
and the 2-second join timeout fires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: close KnowledgeStore connections in connectors_router endpoints
KnowledgeStore opens a persistent SQLite connection in __init__ but was
never closed at the three instantiation sites in connectors_router.py,
leaking one file descriptor (plus its WAL handle) per call. The worst
offender is _connector_summary(), which is invoked per-connector on
every GET /connectors poll from the frontend — after ~100 polls the
backend hits the default macOS per-process FD limit (256) and asyncio's
accept() starts failing with OSError: [Errno 24] Too many open files.
Add __enter__/__exit__ to KnowledgeStore (close() already existed) and
wrap all three instantiation sites in `with` blocks so the connection
is released deterministically when the scope exits.
- connectors/store.py: add context manager protocol
- server/connectors_router.py: use `with KnowledgeStore() as store:`
in _connector_summary, _ingest, and _run_sync
* test: cover KnowledgeStore context manager + connectors_router close leak
- test_store.py: add two tests for the new __enter__/__exit__ protocol
verifying the connection closes on normal exit and on exceptions.
- test_connectors_router.py: add a regression test that monkeypatches
KnowledgeStore.close to count invocations and asserts every store
opened by GET /v1/connectors is paired with a close.
Also fix a pre-existing bug in the test_connectors_router fixture: the
router is created with prefix="/v1/connectors" internally (line 92 of
connectors_router.py), and the fixture was wrapping it again with
prefix="/v1", producing "/v1/v1/connectors". All 6 existing router
tests were failing with 404 before this fix. 5 of them now pass; the
remaining failure (test_trigger_sync) is an unrelated KeyError on a
missing response field and is out of scope for this PR.
* test: drop connectors_router regression test — skipped in CI anyway
The regression test added in the previous commit relies on FastAPI
being importable, but CI's dev-extra install does not include fastapi
(it lives in the `server` optional group). All tests in
test_connectors_router.py silently skip on CI with "fastapi not
installed", so the regression test would never actually execute there.
Revert test_connectors_router.py to the upstream main version. The
fixture bug I fixed (double prefix) and the connection-leak regression
test both deserve a separate PR scoped to test infrastructure — that
PR should either add fastapi to dev deps, split server tests into
their own CI job, or both.
The KnowledgeStore context manager tests in test_store.py are kept
because they have no fastapi dependency and will run in CI.
* feat(mining): add Pearl model conversion workflow
* test(mining): tolerate missing Docker device request type
* docs(mining): record Gemma and Qwen conversion evidence
* docs(mining): record Qwen local validation evidence
---------
Co-authored-by: krypticmouse <herumbshandilya123@gmail.com>