diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e927727..be0bb8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Install Lex toolchain run: | set -euxo pipefail - LEX_VERSION="0.10.7" + LEX_VERSION="0.10.10" curl -fsSL -o /tmp/lex.tgz \ "https://github.com/alpibrusl/lex-lang/releases/download/v${LEX_VERSION}/lex-v${LEX_VERSION}-x86_64-unknown-linux-gnu.tar.gz" tar -xzf /tmp/lex.tgz -C /tmp/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b750b03..1e41594 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,7 +27,7 @@ jobs: - name: Install lex if: steps.ver.outputs.changed == 'true' run: | - curl -fsSL "https://github.com/alpibrusl/lex-lang/releases/download/v0.10.7/lex-v0.10.7-x86_64-unknown-linux-gnu.tar.gz" \ + curl -fsSL "https://github.com/alpibrusl/lex-lang/releases/download/v0.10.10/lex-v0.10.10-x86_64-unknown-linux-gnu.tar.gz" \ | tar -xz --strip-components=1 -C /usr/local/bin - name: Mint JWT + publish diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..94e2d1d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# lex-code — Agent Guidelines + +A Lex-native coding assistant — agents, tools, TUI, and A2A/ACP +servers, all written in Lex. Read `lex agent-guidelines` in full +before writing code. The four highest-leverage discipline rules: + +1. **Narrow effects, always.** `fn foo() -> [fs_write("/tmp/x")] T`, + not `[fs_write]`. If the type checker rejects, narrow the body. +2. **Repair, don't regenerate.** `lex check --output json` → + `lex repair --apply`. Only regenerate after two failed repairs. +3. **`examples {}` blocks on every pure fn.** They fold into the + SigId and run at `lex check` time. +4. **Use the stdlib.** `std.crypto` for hashing, `std.regex` over + hand-rolled scanners. + +## The loop + +```sh +lex pkg install # resolves lex-llm, lex-agent, lex-trail, lex-spec, lex-schema, ... +lex check # this repo has no tests/ dir — see below +lex fmt --check src/ +``` + +There is no `tests/` directory. CI type-checks every file in `src/` +individually, then gates on the manifesto demo examples: + +```sh +lex check examples/manifesto_parallel.lex # must pass +lex check examples/manifesto_parallel_bad.lex # must FAIL (missing [concurrent]) +bash examples/manifesto_semantic_diff/run.sh # semantic diff must surface the effect-row change +``` + +Treat those three as the regression suite until a real `tests/` +directory exists. + +## Project-specific overrides — lex-code + +- **Two call sites per agent mode, times seven files.** Each + `src/agents/*.lex` (build, explore, plan, refactor, review, + spec_agent, test_agent) defines one `AgentLoop`-builder per + provider: `agent()` (Anthropic), `openai_agent()`, `mistral_agent()`, + `google_agent()`, `vertex_agent()`, `ollama_agent()`, + `vllm_agent()`, `litellm_agent()`, `opencode_agent()`. Adding a + provider means adding the function to **all seven** files *and* a + matching branch in `src/server/session.lex`'s `pick_agent` — a + missing branch is a silent fallthrough, not a type error. +- **`select_provider_tag` (TUI) and `pick_agent` (session) must move + together.** A new `--flag` in `src/tui/main.lex` with no matching + `"tag" => ...` arm in `session.lex`'s `pick_agent` silently no-ops + rather than failing to compile — always change both in the same + commit. +- **Local-model tool budgets are curated, not accidental.** + `ollama_agent`/`litellm_agent` use `tools.minimal_tools()` (build) + or `[]` (every other mode) instead of `all_tools()` — 38 tool + schemas overwhelm small local models (see the local-model + compatibility table in `README.md`). Don't widen this without + re-testing against a real local model. +- **Permission specs gate tool *visibility*, not just execution.** + `ag.with_permission_gate(base, rules._permission())` (from + `src/permissions/rules.lex`) filters the tool list at construction + time. Every provider variant for a given mode must be wrapped with + the *same* permission spec — don't special-case one provider's tool + list without updating its spec. +- **`src/server/session.lex` is the single turn-handling path.** The + TUI, the A2A server (`src/server/api.lex`), the BeeAI ACP server + (`src/server/acp.lex`), and the Zed Agent Client Protocol server + (`src/server/client_protocol.lex`) are four transports over the same + `Session`/`run_turn_with_provider` (`run_turn_streaming_with_provider` + for the one transport — ACP — that needs a per-step callback). Extend + `session.lex`, don't duplicate turn logic into a transport file. +- **Two different protocols are both called "ACP" in this repo.** + `src/server/acp.lex` is BeeAI's REST-based Agent Communication + Protocol. `src/server/client_protocol.lex` is Zed's JSON-RPC-over- + stdio Agent Client Protocol — an unrelated standard that happens to + share the acronym. Don't rename either file to disambiguate further; + the doc comments at the top of each already do, and the README's + "Server Protocols" section labels them accordingly. +- **LiteLLM config lives in `litellm/`.** `config.yaml` + + `docker-compose.yml` are the reference proxy setup (Ollama local + models, vLLM, ChatGPT/Claude subscription passthrough, OpenCode Go) + — kept in sync with `lex-loom`'s own `litellm/` directory since both + repos are meant to work against the same proxy. Don't fork the model + list; add new entries to both repos together. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1148342 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# CLAUDE.md — lex-code + +> Copy this file into the root of any Lex project repository as +> `CLAUDE.md` (read by Claude Code), `AGENTS.md` (read by Cursor / +> Aider / Codex CLI / Copilot CLI), or both. This repo ships both, +> kept in sync — read `AGENTS.md` in full before writing code. + +This repository is a **Lex** project — a Lex-native coding assistant +(agents, tools, TUI, A2A/ACP servers). `AGENTS.md` carries the full +discipline, including why this repo has no `tests/` directory and how +provider support is wired across seven agent files plus +`session.lex`. + +## The loop + +```sh +lex pkg install +lex check # no tests/ dir — see AGENTS.md for the CI gate +lex fmt --check src/ +``` + +See `AGENTS.md` for the full discipline. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6102dcd --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +European Union Public Licence v. 1.2 (EUPL-1.2) + +Copyright (c) 2026 lex-code contributors + +Licensed under the EUPL, Version 1.2 only (the "Licence"); you may not +use this work except in compliance with the Licence. + +You may obtain a copy of the Licence at: + + https://joinup.ec.europa.eu/sites/default/files/custom-page/attachment/eupl_v1.2_en.pdf + https://eupl.eu/1.2/en/ (HTML, all 23 EU languages) + https://spdx.org/licenses/EUPL-1.2.html (SPDX identifier: EUPL-1.2) + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the Licence for the specific language governing +permissions and limitations under the Licence. + +The EUPL-1.2 is a copyleft licence approved by the European Commission +and is compatible with several other open-source licences listed in +its Appendix (including GPL-2.0, GPL-3.0, AGPL-3.0, LGPL-2.1, LGPL-3.0, +MPL-2.0, EPL-1.0, CeCILL-2.0/2.1, OSL-2.1/3.0, and CC-BY-SA-3.0). diff --git a/README.md b/README.md index f6daf7b..05bf08f 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ lex-code --plan --ollama "how should we structure the session module?" | `--litellm` | LiteLLM proxy | `$LITELLM_MODEL` | none (proxy handles keys) | | `--ollama` | Ollama (local, native API) | `$OLLAMA_MODEL` | none | | `--vllm` | vLLM (local/remote) | `$VLLM_MODEL` | none | +| `--opencode` | OpenCode Go plan (cloud, direct) | `$OPENCODE_MODEL` | `OPENCODE_API_KEY` | ### Ollama @@ -117,34 +118,68 @@ VLLM_MODEL=deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct \ `VLLM_MODEL` defaults to `mistralai/Mistral-7B-Instruct-v0.3`. `VLLM_BASE_URL` defaults to `http://localhost:8000/v1/chat/completions`. -### LiteLLM (local models via proxy) +### OpenCode Go plan -[LiteLLM](https://github.com/BerriAI/litellm) is the recommended path for running local models. It provides an OpenAI-compatible endpoint over any backend (Ollama, vLLM, MLX, …), which gives cleaner tool calling than the native Ollama wire format. +[OpenCode Go](https://opencode.ai/docs/zen) bundles cloud access to several open-weight coding models (DeepSeek, Qwen3, Kimi, GLM, MiniMax, MiMo) behind one subscription key. Two ways to reach it — same key either way: ```sh -# start the LiteLLM proxy (config at project root) -litellm --config litellm_config.yaml --port 4000 +# native (direct to the Go endpoint, no proxy) +export OPENCODE_API_KEY=$(cat ~/.credentials/opencode/key | tr -d '\n') +lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ + src/tui/main.lex main -- --opencode "implement list.zip" + +# override the default model (kimi-k2.7-code) +OPENCODE_MODEL=qwen3.7-max \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ + src/tui/main.lex main -- --opencode "implement list.zip" + +# via the LiteLLM proxy instead (shares one proxy + model list with lex-loom — see below) +LITELLM_MODEL=deepseek-v4-flash \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ + src/tui/main.lex main -- --litellm "implement list.zip" +``` + +`OPENCODE_MODEL` accepts any Go-plan model id (see `litellm/config.yaml`'s "OpenCode Go plan" section for the full list). `OPENCODE_BASE_URL` overrides the endpoint if you're routing through a local reasoning proxy instead of hitting `opencode.ai` directly. + +### LiteLLM (local models + OpenCode Go via proxy) + +[LiteLLM](https://github.com/BerriAI/litellm) is the recommended path for running local models, and the only path that gives OpenCode Go's thinking-mode models correct `merge_reasoning_content_in_choices` handling. It provides an OpenAI-compatible endpoint over any backend (Ollama, vLLM, OpenCode Go, MLX, …), which gives cleaner tool calling than the native Ollama wire format. + +This repo ships a ready-to-run proxy config at `litellm/config.yaml` + `litellm/docker-compose.yml` — kept in sync with [lex-loom](https://github.com/alpibrusl/lex-loom)'s own `litellm/` directory (same model list, same OpenCode Go entries) so both repos can point at one shared proxy instance. + +```sh +# start the bundled proxy +cd litellm +ANTHROPIC_API_KEY=... OPENAI_API_KEY=... OPENCODE_API_KEY=... docker compose up -d +cd .. # run lex-code against qwen3-coder:30b (recommended local model) LITELLM_MODEL=qwen3-coder:30b \ - lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ src/tui/main.lex main # one-shot via the --litellm flag LITELLM_MODEL=qwen3-coder:30b \ - lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ + src/tui/main.lex main -- --litellm "implement list.zip" + +# OpenCode Go through the proxy instead of native --opencode +LITELLM_MODEL=kimi-k2.7-code \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ src/tui/main.lex main -- --litellm "implement list.zip" # override the proxy URL (default: http://localhost:4000) LITELLM_BASE_URL=http://gpu-box:4000 \ LITELLM_MODEL=qwen3-coder:30b \ - lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ src/tui/main.lex main -- --litellm ``` -`LITELLM_MODEL` is the model name as it appears in your `litellm_config.yaml` `model_name` field. +`LITELLM_MODEL` is the model name as it appears in `litellm/config.yaml`'s `model_name` field. `LITELLM_BASE_URL` defaults to `http://localhost:4000`. +Running against a standalone LiteLLM install instead of the bundled compose file works the same way — point `litellm --config --port 4000` at any config with the model names you use. + #### Local model compatibility Tested on [lex-code fizzbuzz bootstrap](src/bootstrap/fizzbuzz_lex.lex) — task: write `fizzbuzz.lex` with `fn fizzbuzz(n :: Int) -> List[Str]` + 4 unit tests, `lex check` clean, `run_all` returns 0. @@ -165,7 +200,7 @@ curl -s http://localhost:4000/v1/chat/completions \ > /dev/null LITELLM_MODEL=qwen3-coder:30b \ - lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ src/bootstrap/fizzbuzz_lex.lex main # [fizzbuzz_lex] starting build via litellm # [fizzbuzz_lex] done — steps: 71 @@ -244,6 +279,26 @@ curl -X POST http://localhost:8080/runs/stream \ # data: {"run_id":"...","agent_id":"lex-code","status":"completed","output":[...]} ``` +### Agent Client Protocol (ACP, Zed) — Phase 1 + +Not the same "ACP" as above — this is [Zed's Agent Client Protocol](https://zed.dev/acp), an unrelated +JSON-RPC-over-stdio standard for launching a coding agent as a subprocess (Zed, JetBrains, Neovim, and +Emacs all speak it; opencode is one of the other agents already on the [ACP Registry](https://zed.dev/blog/acp-registry)). + +```sh +LEX_CODE_PROVIDER=anthropic ANTHROPIC_API_KEY=… \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,crypto,random,approval \ + src/server/client_protocol.lex main +``` + +Phase 1 covers `initialize`, `session/new`, `session/prompt` (streaming `session/update` +notifications per step), and `session/close` — enough to work from an ACP-aware editor. Not yet +implemented: `session/request_permission`, `$/cancel_request`, client-mediated `fs/*`/`terminal/*`, +and `auth/login` — see the header comment in `src/server/client_protocol.lex` for why each is +deferred rather than silently missing. The exact `session/update` field shapes are a best-effort +reconstruction of the protocol's v2 schema; validate against a real client before relying on this +for production interop. + ## Tools ### Standard tools (all modes) @@ -406,6 +461,14 @@ authorised to use. - [x] v0.3 — parallel multi-agent (`std.conc`), VSCode extension, web frontend, bootstrap script - [x] v0.4 — lex-vcs tools (17), CLI one-shot mode, Ollama + vLLM providers, install target - [x] v0.5 — ACP server (`src/server/acp.lex`), ACP helpers in lex-agent +- [x] v0.6 — OpenCode Go provider (native + via the bundled LiteLLM proxy, shared config with lex-loom) +- [x] v0.7 — Agent Client Protocol (Zed) server, Phase 1: `initialize`/`session/new`/`session/prompt`/`session/close` + +--- + +## License + +EUPL-1.2 — matches the rest of the lex ecosystem. --- diff --git a/lex.toml b/lex.toml index ced9807..bc7e437 100644 --- a/lex.toml +++ b/lex.toml @@ -1,7 +1,9 @@ [package] -name = "lex-code" -version = "0.1.0" -lex = "0.9.7" +name = "lex-code" +version = "0.1.0" +lex = "0.10.10" +license = "EUPL-1.2" +description = "A Lex-native coding assistant — build/plan/explore/refactor/spec/test/review agents, TUI + A2A + ACP servers, lex-vcs tools." [dependencies] lex-llm = { git = "https://github.com/alpibrusl/lex-llm" } @@ -14,6 +16,7 @@ lex-web = { git = "https://github.com/alpibrusl/lex-web" } lex-orm = { git = "https://github.com/alpibrusl/lex-orm" } lex-mcp = { git = "https://github.com/alpibrusl/lex-mcp" } lex-agent-llm = { git = "https://github.com/alpibrusl/lex-agent-llm" } +lex-memory = { git = "https://github.com/alpibrusl/lex-memory" } [bin] name = "lex-code" diff --git a/litellm/config.yaml b/litellm/config.yaml new file mode 100644 index 0000000..91a4e12 --- /dev/null +++ b/litellm/config.yaml @@ -0,0 +1,302 @@ +# LiteLLM proxy config for lex-code — kept in sync with lex-loom's own +# litellm/config.yaml (same model list, including the OpenCode Go plan) +# since both repos are meant to work against the same proxy instance. +# +# Two auth modes: +# +# 1. API keys (Anthropic / OpenAI): +# Set ANTHROPIC_API_KEY and/or OPENAI_API_KEY in your environment before +# running docker compose, then use model names like claude-sonnet-4-6. +# +# 2. ChatGPT subscription (no API key needed): +# LiteLLM uses an OAuth device flow on first request — it prints a URL, +# you open it in a browser, and the token is cached in auth.json. +# Use model names prefixed with chatgpt/: chatgpt/gpt-5.3-chat-latest +# +# 3. Claude Code Max subscription: +# forward_client_headers_to_llm_api forwards the OAuth token that Claude +# Code places in the Authorization header. Set ANTHROPIC_BASE_URL=http://localhost:4000 +# in Claude Code's env to route it through this proxy for cost tracking. +# Note: lex-code itself still needs ANTHROPIC_API_KEY to talk to Claude. +# +# 4. OpenCode Go plan (see the "OpenCode Go plan" section below): +# Set OPENCODE_API_KEY before docker compose up, then point LITELLM_MODEL +# at any entry in that section (deepseek-v4-flash, qwen3.7-max, kimi-k3, ...). +# Prefer this over lex-code's native --opencode flag when you want a single +# proxy shared with lex-loom, request-timeout/retry policy, or cost tracking. +# +# Usage: +# cd litellm +# ANTHROPIC_API_KEY=... OPENAI_API_KEY=... OPENCODE_API_KEY=... docker compose up -d +# +# # lex-code via API key: +# LITELLM_BASE_URL=http://localhost:4000 LITELLM_MODEL=claude-sonnet-4-6 \ +# lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time src/tui/main.lex main -- --litellm "implement list.zip" +# +# # lex-code via OpenCode Go through the proxy: +# LITELLM_BASE_URL=http://localhost:4000 LITELLM_MODEL=kimi-k2.7-code \ +# lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time src/tui/main.lex main -- --litellm "implement list.zip" +# +# # lex-code via ChatGPT subscription: +# LITELLM_BASE_URL=http://localhost:4000 LITELLM_MODEL=chatgpt/gpt-5.3-chat-latest \ +# lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time src/tui/main.lex main -- --litellm "implement list.zip" + +model_list: + + # ── Ollama local models (host machine) ──────────────────────────────────── + # Requires Ollama running on the host at port 11434. + # From Docker: api_base uses host.docker.internal (add extra_hosts in compose). + # From host: set OLLAMA_HOST=http://localhost:11434 or leave default. + # + # Recommended for code tasks: qwen3-coder:30b + # ollama pull qwen3-coder:30b + # LITELLM_BASE_URL=http://localhost:4000 MODEL=qwen3-coder:30b lex run src/main.lex + # + # NOTE on thinking models (gemma4:26b, deepseek-r1): + # These models spend 500-700 tokens on chain-of-thought BEFORE producing output. + # max_tokens must be >= 2000 or they return empty content. merge_reasoning_content_in_choices + # is required so the visible text is not silently dropped by LiteLLM. + # Tool calling with these models is unreliable under large context (10+ tools). + # Prefer qwen3-coder:30b or devstral-small-2 for code agent tasks. + + - model_name: qwen3-coder:30b + litellm_params: + model: ollama/qwen3-coder:30b + api_base: http://host.docker.internal:11434 + + - model_name: devstral-small-2:latest + litellm_params: + model: ollama/devstral-small-2:latest + api_base: http://host.docker.internal:11434 + + - model_name: gemma4:26b + litellm_params: + model: ollama/gemma4:26b + api_base: http://host.docker.internal:11434 + merge_reasoning_content_in_choices: true + + - model_name: gemma4:latest + litellm_params: + model: ollama/gemma4:latest + api_base: http://host.docker.internal:11434 + merge_reasoning_content_in_choices: true + + # ── Anthropic API key ────────────────────────────────────────────────────── + - model_name: claude-opus-4-7 + litellm_params: + model: anthropic/claude-opus-4-7 + api_key: os.environ/ANTHROPIC_API_KEY + + - model_name: claude-sonnet-4-6 + litellm_params: + model: anthropic/claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY + + - model_name: claude-haiku-4-5 + litellm_params: + model: anthropic/claude-haiku-4-5-20251001 + api_key: os.environ/ANTHROPIC_API_KEY + + # ── OpenAI API key ───────────────────────────────────────────────────────── + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + api_key: os.environ/OPENAI_API_KEY + + - model_name: gpt-4o-mini + litellm_params: + model: openai/gpt-4o-mini + api_key: os.environ/OPENAI_API_KEY + + - model_name: o3-mini + litellm_params: + model: openai/o3-mini + api_key: os.environ/OPENAI_API_KEY + + # ── ChatGPT subscription (device flow OAuth, no API key needed) ──────────── + # First request prints a browser URL to authenticate. Token is cached. + - model_name: chatgpt/gpt-5.3-chat-latest + litellm_params: + model: chatgpt/gpt-5.3-chat-latest + + - model_name: chatgpt/gpt-5.4 + litellm_params: + model: chatgpt/gpt-5.4 + + - model_name: chatgpt/gpt-5.3-codex + litellm_params: + model: chatgpt/gpt-5.3-codex + + # ── OpenCode Go plan ─────────────────────────────────────────────────────── + # Set OPENCODE_API_KEY=$(cat ~/.credentials/opencode/key) before compose up. + # All Go plan models run in thinking mode — merge_reasoning_content_in_choices + # is required on every entry so reasoning traces don't silently replace content. + # + # Note: tool_choice=required is rejected by all thinking-mode models. + # lex-code uses tool_choice=auto (via lex-llm, shared with lex-loom) so + # this works correctly. + + # DeepSeek + - model_name: deepseek-v4-flash + litellm_params: + model: openai/deepseek-v4-flash + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: deepseek-v4-pro + litellm_params: + model: openai/deepseek-v4-pro + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + # Qwen3 (Alibaba) + - model_name: qwen3.7-max + litellm_params: + model: openai/qwen3.7-max + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: qwen3.7-plus + litellm_params: + model: openai/qwen3.7-plus + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: qwen3.6-plus + litellm_params: + model: openai/qwen3.6-plus + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: qwen3.5-plus + litellm_params: + model: openai/qwen3.5-plus + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + # Kimi (Moonshot AI) + - model_name: kimi-k3 + litellm_params: + model: openai/kimi-k3 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: kimi-k2.7-code + litellm_params: + model: openai/kimi-k2.7-code + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: kimi-k2.6 + litellm_params: + model: openai/kimi-k2.6 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: kimi-k2.5 + litellm_params: + model: openai/kimi-k2.5 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + # MiniMax + - model_name: minimax-m3 + litellm_params: + model: openai/minimax-m3 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: minimax-m2.7 + litellm_params: + model: openai/minimax-m2.7 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: minimax-m2.5 + litellm_params: + model: openai/minimax-m2.5 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + # GLM (Z.ai / Zhipu) + # glm-5.2 confirmed available on Go plan API (local models.json cache was stale) + - model_name: glm-5.2 + litellm_params: + model: openai/glm-5.2 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: glm-5.1 + litellm_params: + model: openai/glm-5.1 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: glm-5 + litellm_params: + model: openai/glm-5 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + # MiMo (Xiaomi) + - model_name: mimo-v2.5-pro + litellm_params: + model: openai/mimo-v2.5-pro + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: mimo-v2.5 + litellm_params: + model: openai/mimo-v2.5 + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: mimo-v2-pro + litellm_params: + model: openai/mimo-v2-pro + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + - model_name: mimo-v2-omni + litellm_params: + model: openai/mimo-v2-omni + api_base: https://opencode.ai/zen/go/v1 + api_key: os.environ/OPENCODE_API_KEY + merge_reasoning_content_in_choices: true + + # ── Google Gemini API key (optional) ────────────────────────────────────── + # - model_name: gemini-2.5-flash + # litellm_params: + # model: gemini/gemini-2.5-flash + # api_key: os.environ/GOOGLE_API_KEY + +litellm_settings: + drop_params: true + request_timeout: 120 + num_retries: 2 + +general_settings: + # Enables Claude Code Max subscription forwarding. + # Claude Code must be configured with ANTHROPIC_BASE_URL=http://localhost:4000 + forward_client_headers_to_llm_api: true + # Uncomment to require a key on all requests: + # master_key: os.environ/LITELLM_API_KEY diff --git a/litellm/docker-compose.yml b/litellm/docker-compose.yml new file mode 100644 index 0000000..ecb75dc --- /dev/null +++ b/litellm/docker-compose.yml @@ -0,0 +1,23 @@ +services: + litellm: + image: ghcr.io/berriai/litellm:main-latest + restart: unless-stopped + ports: + - "4000:4000" + volumes: + - ./config.yaml:/app/config.yaml:ro + command: ["--config", "/app/config.yaml", "--port", "4000"] + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} + OPENCODE_API_KEY: ${OPENCODE_API_KEY:-} + LITELLM_API_KEY: ${LITELLM_API_KEY:-} + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:4000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s diff --git a/src/agents/build.lex b/src/agents/build.lex index 7e36f33..104826c 100644 --- a/src/agents/build.lex +++ b/src/agents/build.lex @@ -51,6 +51,16 @@ fn google_agent() -> [env] ag.AgentLoop { ag.with_permission_gate(base, rules.build_permission()) } +# OpenCode Go plan (https://opencode.ai/docs/zen) — an OpenAI-compatible +# endpoint over full-scale cloud models (DeepSeek, Qwen3, Kimi, GLM, MiniMax, +# MiMo), not a small local model, so it gets the same full tool budget as the +# other cloud providers above rather than litellm/ollama's stripped-down +# treatment. OPENCODE_API_KEY required; OPENCODE_MODEL overrides the default. +fn opencode_agent() -> [env] ag.AgentLoop { + let base := { name: "build", goal: bpo.system(), model: prov.make_model_ref("opencode", tools.opencode_model()), provider: providers.opencode_go(), tools: tools.all_tools(), options: { temperature: None, top_p: None, max_steps: Some(50), max_tokens: None }, permission_spec: None } + ag.with_permission_gate(base, rules.build_permission()) +} + fn vertex_agent() -> [env] ag.AgentLoop { let base := { name: "build", goal: bpo.system(), model: vtx.gemini_35_flash(), provider: providers.vertex(), tools: tools.all_tools(), options: { temperature: None, top_p: None, max_steps: Some(50), max_tokens: None }, permission_spec: None } ag.with_permission_gate(base, rules.build_permission()) diff --git a/src/agents/explore.lex b/src/agents/explore.lex index ded5cd2..e0a1c03 100644 --- a/src/agents/explore.lex +++ b/src/agents/explore.lex @@ -45,3 +45,8 @@ fn google_agent() -> [env] ag.AgentLoop { ag.with_permission_gate(base, rules.explore_permission()) } +fn opencode_agent() -> [env] ag.AgentLoop { + let base := { name: "explore", goal: ep.system(), model: prov.make_model_ref("opencode", tools.opencode_model()), provider: providers.opencode_go(), tools: tools.tools_for_spec(rules.explore_permission()), options: { temperature: None, top_p: None, max_steps: Some(20), max_tokens: None }, permission_spec: None } + ag.with_permission_gate(base, rules.explore_permission()) +} + diff --git a/src/agents/plan.lex b/src/agents/plan.lex index da306b4..49717bb 100644 --- a/src/agents/plan.lex +++ b/src/agents/plan.lex @@ -45,3 +45,8 @@ fn google_agent() -> [env] ag.AgentLoop { ag.with_permission_gate(base, rules.plan_permission()) } +fn opencode_agent() -> [env] ag.AgentLoop { + let base := { name: "plan", goal: pp.system(), model: prov.make_model_ref("opencode", tools.opencode_model()), provider: providers.opencode_go(), tools: tools.tools_for_spec(rules.plan_permission()), options: { temperature: None, top_p: None, max_steps: Some(30), max_tokens: None }, permission_spec: None } + ag.with_permission_gate(base, rules.plan_permission()) +} + diff --git a/src/agents/refactor.lex b/src/agents/refactor.lex index f8109a7..d6f3d8c 100644 --- a/src/agents/refactor.lex +++ b/src/agents/refactor.lex @@ -45,3 +45,8 @@ fn google_agent() -> [env] ag.AgentLoop { ag.with_permission_gate(base, rules.refactor_permission()) } +fn opencode_agent() -> [env] ag.AgentLoop { + let base := { name: "refactor", goal: rp.system(), model: prov.make_model_ref("opencode", tools.opencode_model()), provider: providers.opencode_go(), tools: tools.tools_for_spec(rules.refactor_permission()), options: { temperature: None, top_p: None, max_steps: Some(40), max_tokens: None }, permission_spec: None } + ag.with_permission_gate(base, rules.refactor_permission()) +} + diff --git a/src/agents/review.lex b/src/agents/review.lex index 9b990bb..ac2941e 100644 --- a/src/agents/review.lex +++ b/src/agents/review.lex @@ -45,3 +45,8 @@ fn google_agent() -> [env] ag.AgentLoop { ag.with_permission_gate(base, rules.review_permission()) } +fn opencode_agent() -> [env] ag.AgentLoop { + let base := { name: "review", goal: rvp.system(), model: prov.make_model_ref("opencode", tools.opencode_model()), provider: providers.opencode_go(), tools: tools.tools_for_spec(rules.review_permission()), options: { temperature: None, top_p: None, max_steps: Some(25), max_tokens: None }, permission_spec: None } + ag.with_permission_gate(base, rules.review_permission()) +} + diff --git a/src/agents/spec_agent.lex b/src/agents/spec_agent.lex index b786974..89824f6 100644 --- a/src/agents/spec_agent.lex +++ b/src/agents/spec_agent.lex @@ -45,3 +45,8 @@ fn google_agent() -> [env] ag.AgentLoop { ag.with_permission_gate(base, rules.spec_permission()) } +fn opencode_agent() -> [env] ag.AgentLoop { + let base := { name: "spec", goal: sp.system(), model: prov.make_model_ref("opencode", tools.opencode_model()), provider: providers.opencode_go(), tools: tools.tools_for_spec(rules.spec_permission()), options: { temperature: None, top_p: None, max_steps: Some(30), max_tokens: None }, permission_spec: None } + ag.with_permission_gate(base, rules.spec_permission()) +} + diff --git a/src/agents/test_agent.lex b/src/agents/test_agent.lex index 0746cde..9cdb0ef 100644 --- a/src/agents/test_agent.lex +++ b/src/agents/test_agent.lex @@ -45,3 +45,8 @@ fn google_agent() -> [env] ag.AgentLoop { ag.with_permission_gate(base, rules.test_permission()) } +fn opencode_agent() -> [env] ag.AgentLoop { + let base := { name: "test", goal: tp.system(), model: prov.make_model_ref("opencode", tools.opencode_model()), provider: providers.opencode_go(), tools: tools.tools_for_spec(rules.test_permission()), options: { temperature: None, top_p: None, max_steps: Some(30), max_tokens: None }, permission_spec: None } + ag.with_permission_gate(base, rules.test_permission()) +} + diff --git a/src/bootstrap/hello.lex b/src/bootstrap/hello.lex index 7e5ad8f..5f1a1c0 100644 --- a/src/bootstrap/hello.lex +++ b/src/bootstrap/hello.lex @@ -12,7 +12,7 @@ import "std.list" as list # Ollama) with the curated minimal toolset, asking it to write a Python # "hello world". Validates the full local stack: lex-code agent loop → # LiteLLM (OpenAI tool contracts) → Ollama. -fn main() -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn main() -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { let provider_tag := "litellm" let task := "Create a file named hello.py containing a Python program that prints exactly: Hello, World! Use the write tool to create the file, then stop." io.print("[hello] starting build phase via litellm") diff --git a/src/bootstrap/run.lex b/src/bootstrap/run.lex index 69c1f88..c3ffed8 100644 --- a/src/bootstrap/run.lex +++ b/src/bootstrap/run.lex @@ -10,7 +10,7 @@ import "std.list" as list type Phase = { name :: Str, task :: Str, mode :: sess.AgentMode } -fn run_phase(phase :: Phase, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn run_phase(phase :: Phase, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { io.print(str.concat("[bootstrap] starting phase: ", phase.name)) match sess.new_session_with_provider(phase.name, phase.mode, provider_tag) { Err(e) => io.print(str.concat("[bootstrap] session error: ", e)), @@ -22,11 +22,11 @@ fn run_phase(phase :: Phase, provider_tag :: Str) -> [env, io, net, llm, proc, s } } -fn main() -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn main() -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { let provider_tag := "anthropic" let task := "Add fn zip[A, B](xs :: List[A], ys :: List[B]) -> List[(A, B)] to src/list.lex" let phases := [{ name: "impl", task: task, mode: Build }, { name: "spec", task: str.concat("Write lex-spec Spec for list.zip: ", task), mode: Spec }, { name: "test", task: str.concat("Write unit tests for list.zip: ", task), mode: Test }, { name: "review", task: str.concat("Review the list.zip implementation and tests: ", task), mode: Review }] - let __lex_discard_1 := list.map(phases, fn (phase :: Phase) -> [env, io, net, llm, proc, sql, fs_write, time] Nil { + let __lex_discard_1 := list.map(phases, fn (phase :: Phase) -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { run_phase(phase, provider_tag) }) () diff --git a/src/project_memory.lex b/src/project_memory.lex index 65bf1c9..a431e3b 100644 --- a/src/project_memory.lex +++ b/src/project_memory.lex @@ -4,7 +4,8 @@ # separate from session logs so memory outlives individual sessions and # accumulates across every mode (Build, Explore, Refactor, Review, etc.). # -# Wraps lex-agent/src/memory with project-specific entry points: +# Wraps lex-memory/src/memory (extracted from lex-agent/src/memory, see +# alpibrusl/lex-memory#1) with project-specific entry points: # # convention — coding/style conventions, upserted by name # tech_stack — technology/framework facts, upserted by name @@ -22,7 +23,7 @@ # } # } -import "lex-agent/src/memory" as mem +import "lex-memory/src/memory" as mem import "lex-orm/src/connection" as conn diff --git a/src/server/acp.lex b/src/server/acp.lex index e3cab38..0cd01c6 100644 --- a/src/server/acp.lex +++ b/src/server/acp.lex @@ -6,7 +6,7 @@ import "lex-llm/message" as msg import "std.str" as str -fn handle_run(body :: Str, provider_tag :: Str) -> [env, net, llm, io, proc, sql, fs_write, time] Str { +fn handle_run(body :: Str, provider_tag :: Str) -> [env, net, llm, io, proc, sql, fs_write, time, approval] Str { match acp.parse_run_request(body) { Err(e) => acp.run_response_error("", "lex-code", str.concat("bad request: ", e)), Ok(input) => match sess.new_session_with_provider("acp", Build, provider_tag) { diff --git a/src/server/api.lex b/src/server/api.lex index 0d133f0..fa3de1c 100644 --- a/src/server/api.lex +++ b/src/server/api.lex @@ -30,7 +30,7 @@ fn mode_from_str(mode_str :: Str) -> sess.AgentMode { } } -fn handle_chat(message :: amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc] a2a_srv.HandlerOutcome { +fn handle_chat(message :: amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] a2a_srv.HandlerOutcome { match list.head(message.parts) { Some(TextPart(text)) => match sess.new_session("api", Build) { Err(e) => { next_state: TSFailed, reply: Some(amsg.agent_text(str.concat("session error: ", e))), artifacts: [] }, diff --git a/src/server/client_protocol.lex b/src/server/client_protocol.lex new file mode 100644 index 0000000..aa4573e --- /dev/null +++ b/src/server/client_protocol.lex @@ -0,0 +1,273 @@ +# lex-code — Agent Client Protocol (ACP) server — Phase 1 +# +# NOT the same protocol as src/server/acp.lex, which speaks BeeAI's +# REST-based Agent Communication Protocol. This file speaks Zed's Agent +# Client Protocol — JSON-RPC 2.0 over stdin/stdout, NDJSON-framed (one +# JSON object per line, no Content-Length headers). It's the protocol +# editors use to launch and drive a coding agent as a subprocess: Zed, +# JetBrains (IntelliJ/PyCharm/GoLand/WebStorm), Neovim, and Emacs all +# support it, and opencode is one of the agents already listed in the +# shared ACP Registry (zed.dev/blog/acp-registry). See zed.dev/acp for +# the protocol itself. +# +# Phase 1 (this file): initialize, session/new, session/prompt (with +# streaming session/update notifications), session/close. +# +# Deliberately NOT yet implemented — see the scoping discussion, not +# silently skipped: +# - session/request_permission: today's permission_spec is a static +# filter at agent construction (src/permissions/rules.lex), not an +# interactive round-trip. Wiring this in changes when/how that +# check fires, not just adding a handler. +# - $/cancel_request: the current turn loop is one blocking call to +# completion or max_steps. Honoring a cancel notification that +# arrives mid-turn needs the turn running as a std.conc actor (the +# same pattern src/server/multi_agent.lex already uses for +# --multi) so the stdin-read loop stays free to receive it. +# - fs/* and terminal/*: optional in ACP — an agent may still touch +# the filesystem/shell directly if it declares that capability, +# which is what lex-code's own read_file/write_file/bash tools +# already do. Skipped for Phase 1, not required for correctness. +# - auth/login / auth/logout: lex-code reads provider keys from the +# environment at launch; no interactive auth flow exists to wire. +# +# WIRE-FORMAT CAVEAT: the exact field names below (sessionId, +# protocolVersion, the session/update variant shapes) are this file's +# best-effort reconstruction of the ACP v2 schema from available +# documentation — not a byte-for-byte trace against a reference SDK. +# Validate against a real client (e.g. Zed) before relying on this for +# production interop; the JSON-RPC 2.0 envelope and NDJSON framing +# themselves are unambiguous and were verified directly. +# +# CRITICAL: once serve() starts, stdout carries ONLY protocol frames. +# Unlike mcp_main.lex, there is deliberately no startup banner — any +# non-protocol byte on stdout corrupts the stream from the client's +# point of view. +# +# Run (an editor would spawn this same command as a subprocess): +# LEX_CODE_PROVIDER=anthropic ANTHROPIC_API_KEY=… \ +# lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,crypto,random,approval \ +# src/server/client_protocol.lex main + +import "std.io" as io + +import "std.str" as str + +import "std.list" as list + +import "std.map" as map + +import "std.env" as env + +import "std.crypto" as crypto + +import "lex-schema/json_value" as jv + +import "lex-llm/delta" as d + +import "./session" as sess + +type Registry = Map[Str, sess.Session] + +fn pick(cur :: Str, fallback :: Str) -> Str { + if str.is_empty(cur) { + fallback + } else { + cur + } +} + +# Same env-var convention as src/server/mcp_main.lex's provider_tag_from_env +# — provider is a launch-time choice for a long-running server, not a +# per-call argument. +fn provider_tag_from_env() -> [env] Str { + match env.get("LEX_CODE_PROVIDER") { + Some(t) => pick(t, "anthropic"), + None => "anthropic", + } +} + +# ---- JSON-RPC envelope ----------------------------------------------- +fn jrpc_result(id :: jv.Json, result :: jv.Json) -> jv.Json { + JObj([("jsonrpc", JStr("2.0")), ("id", id), ("result", result)]) +} + +fn jrpc_error(id :: jv.Json, code :: Int, message :: Str) -> jv.Json { + JObj([("jsonrpc", JStr("2.0")), ("id", id), ("error", JObj([("code", JInt(code)), ("message", JStr(message))]))]) +} + +fn jrpc_notification(method :: Str, params :: jv.Json) -> jv.Json { + JObj([("jsonrpc", JStr("2.0")), ("method", JStr(method)), ("params", params)]) +} + +fn send(j :: jv.Json) -> [io] Unit { + io.print(jv.stringify(j)) +} + +fn req_id(req :: jv.Json) -> jv.Json { + match jv.get_field(req, "id") { + Some(i) => i, + None => JNull, + } +} + +fn req_method(req :: jv.Json) -> Str { + match jv.get_field(req, "method") { + Some(JStr(m)) => m, + _ => "", + } +} + +fn req_params(req :: jv.Json) -> jv.Json { + match jv.get_field(req, "params") { + Some(p) => p, + None => JObj([]), + } +} + +fn str_field(j :: jv.Json, key :: Str) -> Str { + match jv.get_field(j, key) { + Some(JStr(s)) => s, + _ => "", + } +} + +# ---- Step -> session/update translation -------------------------------- +# Best-effort mapping onto ACP's session/update "sessionUpdate" variants; +# see the wire-format caveat above. Covers the two updates a human (or an +# editor's chat panel) actually needs to see live: assistant text and tool +# call lifecycle. ToolArgChunk/UsageDelta/StepDone produce no visible update +# (StepDone's content lands in the final session/prompt response instead). +fn step_to_update(session_id :: Str, step :: d.Step) -> Option[jv.Json] { + match step { + StepDelta(delta) => delta_to_update(session_id, delta), + StepToolExec(name, _) => Some(session_update(session_id, "tool_call", [("title", JStr(name)), ("status", JStr("in_progress"))])), + StepToolResult(_, ok) => Some(session_update(session_id, "tool_call_update", [("status", JStr(tool_status(ok)))])), + StepDone(_) => None, + } +} + +fn tool_status(ok :: Bool) -> Str { + if ok { + "completed" + } else { + "failed" + } +} + +fn delta_to_update(session_id :: Str, delta :: d.Delta) -> Option[jv.Json] { + match delta { + TextChunk(text) => Some(session_update(session_id, "agent_message_chunk", [("content", JObj([("type", JStr("text")), ("text", JStr(text))]))])), + ToolCallBegin(id, name) => Some(session_update(session_id, "tool_call", [("toolCallId", JStr(id)), ("title", JStr(name)), ("status", JStr("pending"))])), + ToolArgChunk(_, _) => None, + FinishDelta(_) => None, + UsageDelta(_, _, _) => None, + } +} + +fn session_update(session_id :: Str, kind :: Str, fields :: List[(Str, jv.Json)]) -> jv.Json { + jrpc_notification("session/update", JObj([("sessionId", JStr(session_id)), ("update", JObj(list.concat([("sessionUpdate", JStr(kind))], fields)))])) +} + +# ---- Method handlers ---------------------------------------------------- +fn handle_initialize(id :: jv.Json) -> [io] Unit { + send(jrpc_result(id, JObj([("protocolVersion", JInt(2)), ("agentCapabilities", JObj([("loadSession", JBool(false)), ("promptCapabilities", JObj([("image", JBool(false)), ("audio", JBool(false))]))]))]))) +} + +fn handle_session_new(id :: jv.Json, params :: jv.Json, registry :: Registry, provider_tag :: Str) -> [sql, fs_write, crypto, random, io] Registry { + let sid := crypto.random_str_hex(16) + match sess.new_session_with_provider(sid, Build, provider_tag) { + Err(e) => { + let __sent := send(jrpc_error(id, -32000, str.concat("session/new failed: ", e))) + registry + }, + Ok(s) => { + let __sent := send(jrpc_result(id, JObj([("sessionId", JStr(sid))]))) + map.set(registry, sid, s) + }, + } +} + +fn handle_session_prompt(id :: jv.Json, params :: jv.Json, registry :: Registry, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time, approval] Registry { + let sid := str_field(params, "sessionId") + let prompt := extract_prompt_text(params) + match map.get(registry, sid) { + None => { + let __sent := send(jrpc_error(id, -32001, str.concat("unknown sessionId: ", sid))) + registry + }, + Some(session) => { + let result := sess.run_turn_streaming_with_provider(session, prompt, provider_tag, fn (step :: d.Step) -> [io] Unit { + match step_to_update(sid, step) { + None => (), + Some(update) => send(update), + } + }) + let __sent := send(jrpc_result(id, JObj([("stopReason", JStr("end_turn"))]))) + map.set(registry, sid, result.session) + }, + } +} + +# ACP's prompt param is a list of content blocks (usually one {"type":"text", +# "text":...}); concatenate any text blocks so a multi-block prompt still +# reaches the agent as one turn instead of silently dropping blocks 2..n. +fn extract_prompt_text(params :: jv.Json) -> Str { + match jv.get_field(params, "prompt") { + Some(JList(blocks)) => str.join(list.map(blocks, block_text), ""), + _ => "", + } +} + +fn block_text(block :: jv.Json) -> Str { + str_field(block, "text") +} + +fn handle_session_close(id :: jv.Json, params :: jv.Json, registry :: Registry) -> [io] Registry { + let sid := str_field(params, "sessionId") + let __sent := send(jrpc_result(id, JObj([]))) + map.delete(registry, sid) +} + +# ---- Dispatch + main loop ------------------------------------------------ +fn dispatch(line :: Str, registry :: Registry, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time, crypto, random, fs_write, approval] Registry { + match jv.parse_into_errors(line) { + Err(_) => { + let __sent := send(jrpc_error(JNull, -32700, "parse error")) + registry + }, + Ok(req) => { + let id := req_id(req) + match req_method(req) { + "initialize" => { + let __sent := handle_initialize(id) + registry + }, + "session/new" => handle_session_new(id, req_params(req), registry, provider_tag), + "session/prompt" => handle_session_prompt(id, req_params(req), registry, provider_tag), + "session/close" => handle_session_close(id, req_params(req), registry), + "" => registry, + other => { + let __sent := send(jrpc_error(id, -32601, str.concat("method not found: ", other))) + registry + }, + } + }, + } +} + +fn serve(registry :: Registry, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time, crypto, random, fs_write, approval] Nil { + match io.readline() { + None => (), + Some(line) => if str.is_empty(str.trim(line)) { + serve(registry, provider_tag) + } else { + serve(dispatch(line, registry, provider_tag), provider_tag) + }, + } +} + +fn main() -> [env, net, llm, io, proc, sql, time, crypto, random, fs_write, approval] Nil { + serve(map.new(), provider_tag_from_env()) +} + diff --git a/src/server/mcp_main.lex b/src/server/mcp_main.lex index bccd11e..64afb2c 100644 --- a/src/server/mcp_main.lex +++ b/src/server/mcp_main.lex @@ -127,8 +127,8 @@ fn extract(parts :: List[amsg.Part]) -> Args { } # ---- handler: select a brain by mode, run the loop, map via bridge -- -fn make_handler(brains :: Brains) -> (amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc] srv.HandlerOutcome { - fn (m :: amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc] srv.HandlerOutcome { +fn make_handler(brains :: Brains) -> (amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] srv.HandlerOutcome { + fn (m :: amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] srv.HandlerOutcome { let a := extract(m.parts) let brain := brain_for(brains, a.mode) bridge.outcome_of_steps(iter.to_list(ag.run_loop(brain, [lmsg.user(a.task)]))) @@ -147,7 +147,7 @@ fn make_agent(brains :: Brains) -> srv.AgentDef { srv.make_agent_def(card.make("lex-code", "Lex-specialized coding agent — A2A + MCP. One `code` tool; `mode` selects the strategy.", "0.1.0", "http://localhost:7778", [code_capability()]), [{ capability: code_capability(), handle: make_handler(brains) }]) } -fn main() -> [env, io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc] Nil { +fn main() -> [env, io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] Nil { let tag := provider_tag_from_env() let brains := build_brains(tag) let __msg1 := print_line(str.concat("lex-code listening on :7778 (A2A + MCP), provider=", tag)) diff --git a/src/server/multi_agent.lex b/src/server/multi_agent.lex index 1645bc1..f1e9259 100644 --- a/src/server/multi_agent.lex +++ b/src/server/multi_agent.lex @@ -8,7 +8,7 @@ import "./session" as sess type MultiResult = { impl_steps :: List[d.Step], test_steps :: List[d.Step] } -fn run_task(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, net, llm, io, proc, sql, fs_write, time] List[d.Step] { +fn run_task(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, net, llm, io, proc, sql, fs_write, time, approval] List[d.Step] { match sess.new_session_with_provider("worker", mode, provider_tag) { Err(_) => [], Ok(session) => { @@ -18,9 +18,9 @@ fn run_task(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, n } } -fn run_parallel(task :: Str, provider_tag :: Str) -> [env, concurrent, net, llm, io, proc, sql, fs_write, time] MultiResult { +fn run_parallel(task :: Str, provider_tag :: Str) -> [env, concurrent, net, llm, io, proc, sql, fs_write, time, approval] MultiResult { let pairs := [(task, Build), (str.concat("Write unit tests for: ", task), Test)] - let results := list.par_map(pairs, fn (pair :: (Str, sess.AgentMode)) -> [env, net, llm, io, proc, sql, fs_write, time] List[d.Step] { + let results := list.par_map(pairs, fn (pair :: (Str, sess.AgentMode)) -> [env, net, llm, io, proc, sql, fs_write, time, approval] List[d.Step] { match pair { (t, mode) => run_task(t, mode, provider_tag), } @@ -36,17 +36,17 @@ fn run_parallel(task :: Str, provider_tag :: Str) -> [env, concurrent, net, llm, { impl_steps: impl_steps, test_steps: test_steps } } -fn run_impl_then_test(task :: Str, provider_tag :: Str) -> [env, net, llm, io, proc, sql, fs_write, time] MultiResult { +fn run_impl_then_test(task :: Str, provider_tag :: Str) -> [env, net, llm, io, proc, sql, fs_write, time, approval] MultiResult { let impl_steps := run_task(task, Build, provider_tag) let test_steps := run_task(str.concat("Write unit tests for: ", task), Test, provider_tag) { impl_steps: impl_steps, test_steps: test_steps } } -fn run_impl_then_spec_then_test(task :: Str, provider_tag :: Str) -> [env, concurrent, net, llm, io, proc, sql, fs_write, time] List[d.Step] { +fn run_impl_then_spec_then_test(task :: Str, provider_tag :: Str) -> [env, concurrent, net, llm, io, proc, sql, fs_write, time, approval] List[d.Step] { let impl_steps := run_task(task, Build, provider_tag) let spec_steps := run_task(str.concat("Write lex-spec Spec for: ", task), Spec, provider_tag) let parallel_tasks := [(str.concat("Write unit tests for: ", task), Test), (str.concat("Review implementation: ", task), Review)] - let parallel_results := list.par_map(parallel_tasks, fn (pair :: (Str, sess.AgentMode)) -> [env, net, llm, io, proc, sql, fs_write, time] List[d.Step] { + let parallel_results := list.par_map(parallel_tasks, fn (pair :: (Str, sess.AgentMode)) -> [env, net, llm, io, proc, sql, fs_write, time, approval] List[d.Step] { match pair { (t, mode) => run_task(t, mode, provider_tag), } diff --git a/src/server/session.lex b/src/server/session.lex index d677835..fb7aab2 100644 --- a/src/server/session.lex +++ b/src/server/session.lex @@ -103,6 +103,15 @@ fn pick_agent(mode :: AgentMode, provider_tag :: Str) -> [env] ag.AgentLoop { Test => test_a.google_agent(), Review => review_a.google_agent(), }, + "opencode" => match mode { + Build => build_agent.opencode_agent(), + Plan => plan_agent.opencode_agent(), + Explore => explore_agent.opencode_agent(), + Refactor => refactor_agent.opencode_agent(), + Spec => spec_a.opencode_agent(), + Test => test_a.opencode_agent(), + Review => review_a.opencode_agent(), + }, _ => match mode { Build => build_agent.agent(), Plan => plan_agent.agent(), @@ -126,11 +135,11 @@ fn new_session_with_provider(id :: Str, mode :: AgentMode, provider_tag :: Str) } } -fn run_turn(session :: Session, user_input :: Str) -> [env, net, llm, io, proc, sql, time] TurnResult { +fn run_turn(session :: Session, user_input :: Str) -> [env, net, llm, io, proc, sql, time, approval] TurnResult { run_turn_with_provider(session, user_input, "anthropic") } -fn run_turn_with_provider(session :: Session, user_input :: Str, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time] TurnResult { +fn run_turn_with_provider(session :: Session, user_input :: Str, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time, approval] TurnResult { let user_msg := msg.user(user_input) let messages := list.concat(session.messages, [user_msg]) let agent := pick_agent(session.mode, provider_tag) @@ -145,6 +154,43 @@ fn run_turn_with_provider(session :: Session, user_input :: Str, provider_tag :: { steps: steps, session: updated } } +# Same turn-handling as run_turn_with_provider, but invokes on_step for each +# Step as it's pulled off the loop's Iter instead of collecting the whole +# thing first — so a caller (the ACP server) can emit a protocol notification +# per step. Note this is NOT token-level real-time streaming: run_loop_traced +# already runs the full LLM/tool loop to completion internally before +# returning its Iter (see lex-llm/src/agent.lex's run_loop_traced, which +# wraps an already-materialized list via iter.from_list) — on_step fires in a +# tight burst right after the blocking call returns, in step order, not +# interleaved with live token generation. True interleaved streaming would +# need a callback threaded into lex-llm's own loop, a larger change. +fn run_turn_streaming_with_provider(session :: Session, user_input :: Str, provider_tag :: Str, on_step :: (d.Step) -> [io] Unit) -> [env, net, llm, io, proc, sql, time, approval] TurnResult { + let user_msg := msg.user(user_input) + let messages := list.concat(session.messages, [user_msg]) + let agent := pick_agent(session.mode, provider_tag) + let step_iter := ag.run_loop_traced(agent, messages, session.log, session.parent) + let steps := drain_with_callback(step_iter, on_step) + let final_msg := find_done_msg(steps) + let new_msgs := match final_msg { + None => messages, + Some(m) => list.concat(messages, [m]), + } + let updated := { id: session.id, mode: session.mode, messages: new_msgs, log: session.log, parent: None } + { steps: steps, session: updated } +} + +fn drain_with_callback(it :: Iter[d.Step], on_step :: (d.Step) -> [io] Unit) -> [io] List[d.Step] { + match iter.next(it) { + None => [], + Some(p) => match p { + (step, rest) => { + let __emitted := on_step(step) + list.concat([step], drain_with_callback(rest, on_step)) + }, + }, + } +} + fn find_done_msg(steps :: List[d.Step]) -> Option[msg.Message] { match list.head(list.filter(steps, is_done)) { None => None, diff --git a/src/server/web.lex b/src/server/web.lex index 9aa15c0..65e7cee 100644 --- a/src/server/web.lex +++ b/src/server/web.lex @@ -160,7 +160,7 @@ fn steps_to_json(steps :: List[d.Step]) -> Str { } # ── A2A handler (carries [env] — bypasses router) ───────────────────────────── -fn handle_a2a_body(body :: Str) -> [env, io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc] resp.Response { +fn handle_a2a_body(body :: Str) -> [env, io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] resp.Response { match jv.parse(body) { Err(_) => resp.bad_request("invalid JSON"), Ok(j) => { @@ -194,7 +194,7 @@ fn handle_a2a_body(body :: Str) -> [env, io, time, crypto, random, sql, fs_read, # ── Static-only router (no [env] routes) ───────────────────────────────────── fn build_static_router(web_dir :: Str) -> router.Router { let r0 := router.new() - let r1 := router.route_effectful(r0, "GET", "/", fn (c :: ctx.Ctx) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc] resp.Response { + let r1 := router.route_effectful(r0, "GET", "/", fn (c :: ctx.Ctx) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] resp.Response { match io.read(str.concat(web_dir, "/index.html")) { Ok(html) => resp.html(html), Err(_) => resp.not_found(), @@ -204,12 +204,12 @@ fn build_static_router(web_dir :: Str) -> router.Router { } # ── Entry point ─────────────────────────────────────────────────────────────── -fn serve_web() -> [env, net, io, llm, proc, sql, fs_read, fs_write, time, crypto, random, concurrent] Unit { +fn serve_web() -> [env, net, io, llm, proc, sql, fs_read, fs_write, time, crypto, random, concurrent, approval] Unit { let port := parse_int_or_w(get_env_w("PORT", "7700"), 7700) let web_dir := get_env_w("WEB_DIR", "src/web") let r := build_static_router(web_dir) let __p := io.print(str.join(["[lex-code] web on :", int.to_str(port), " static=", web_dir], "")) - net.serve_fn(port, fn (req :: Request) -> [env, io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc] Response { + net.serve_fn(port, fn (req :: Request) -> [env, io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] Response { if req.method == "OPTIONS" { let rsp := with_cors(resp.no_content()) { status: rsp.status, body: BodyStr(rsp.body), headers: rsp.headers } diff --git a/src/tools/index.lex b/src/tools/index.lex index 0bde9b5..d31d9a3 100644 --- a/src/tools/index.lex +++ b/src/tools/index.lex @@ -124,6 +124,18 @@ fn litellm_model() -> [env] Str { } } +# Model name for the native OpenCode Go provider (providers.opencode_go(), +# hits https://opencode.ai/zen/go/v1 directly — no local proxy needed). +# Default is a coding-oriented model on the Go plan; override with +# OPENCODE_MODEL. See litellm/config.yaml for the full Go-plan model list +# (also reachable via --litellm once the proxy is running). +fn opencode_model() -> [env] Str { + match env.get("OPENCODE_MODEL") { + None => "kimi-k2.7-code", + Some(m) => m, + } +} + # ── Dynamic toolset loading ───────────────────────────────────────────────── # Each gate is a lex-spec predicate that is `Allow` only when the matching # `loaded_*` binding is true. lex-llm sets those bindings by scanning the diff --git a/src/tui/main.lex b/src/tui/main.lex index f37a354..73a0f75 100644 --- a/src/tui/main.lex +++ b/src/tui/main.lex @@ -32,7 +32,7 @@ fn print_step(step :: d.Step) -> [io] Nil { } } -fn repl(session :: sess.Session, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn repl(session :: sess.Session, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { io.print("\n> ") match io.read("-") { Err(_) => io.print("\nbye"), @@ -51,7 +51,7 @@ fn repl(session :: sess.Session, provider_tag :: Str) -> [env, io, net, llm, pro } } -fn run_once(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn run_once(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { match sess.new_session_with_provider("cli", mode, provider_tag) { Err(e) => io.print(str.concat(str.concat("error: ", e), "\n")), Ok(session) => { @@ -64,7 +64,7 @@ fn run_once(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, i } } -fn multi_repl(provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn multi_repl(provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { io.print("\n[multi] task> ") match io.read("-") { Err(_) => io.print("\nbye"), @@ -162,7 +162,11 @@ fn select_provider_tag(argv :: List[Str]) -> Str { if has_flag(argv, "--vllm") { "vllm" } else { - "anthropic" + if has_flag(argv, "--opencode") { + "opencode" + } else { + "anthropic" + } } } } @@ -176,14 +180,14 @@ fn select_provider_tag(argv :: List[Str]) -> Str { # # Usage (from the lex-code source directory): # lex run src/tui/main.lex run_headless '""' '"ollama"' \ -# --allow-effects env,io,net,llm,proc,sql,fs_write,time,concurrent +# --allow-effects env,io,net,llm,proc,sql,fs_write,time,concurrent,approval # # Emits streaming progress to stdout (tool names + text chunks), then on # the last line emits a machine-readable sentinel the adapter can parse: # # [AGENTCMP_RESULT] {"ok":true,"final":""} # -fn run_headless(task :: Str, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn run_headless(task :: Str, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { match sess.new_session_with_provider("headless", Build, provider_tag) { Err(e) => io.print(str.join(["[AGENTCMP_RESULT]\t{\"ok\":false,\"final\":\"", e, "\"}\n"], "")), Ok(session) => { @@ -235,7 +239,7 @@ fn collect_final_text(steps :: List[d.Step]) -> Str { } } -fn main() -> [env, io, net, llm, proc, sql, fs_write, time] Nil { +fn main() -> [env, io, net, llm, proc, sql, fs_write, time, approval] Nil { let argv := [] let provider_tag := select_provider_tag(argv) let mode := select_mode(argv) @@ -244,7 +248,7 @@ fn main() -> [env, io, net, llm, proc, sql, fs_write, time] Nil { None => { io.print(str.concat("lex-code — Lex-specialized coding assistant", "\n")) io.print(str.concat("modes: --plan | --explore | --refactor | --spec | --test | --review | --multi", "\n")) - io.print(str.concat("providers: --mistral | --openai | --google | --vertex | --ollama | --vllm (default: anthropic)", "\n")) + io.print(str.concat("providers: --mistral | --openai | --google | --vertex | --litellm | --ollama | --vllm | --opencode (default: anthropic)", "\n")) io.print(str.concat("one-shot: lex run src/tui/main.lex -- [flags] \"your task\"", "\n")) io.print(str.concat("Ctrl-D to exit", "\n")) if has_flag(argv, "--multi") {