From 794eec76eefdd3e71ce87d56304c2c2cca9421cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:13:05 +0000 Subject: [PATCH 1/7] Add OpenCode Go provider support and bring repo up to ecosystem convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lex-code was three weeks stale relative to the rest of the lex ecosystem and lacked native support for the OpenCode Go plan (https://opencode.ai/docs/zen) that lex-loom already uses. OpenCode Go support (native + via the LiteLLM proxy, matching lex-loom): - New `opencode_agent()` in all seven src/agents/*.lex files (build/explore/plan/refactor/review/spec_agent/test_agent), using lex-llm's existing `providers.opencode_go()` — no new package code needed there. Since the Go plan serves full-scale cloud models (DeepSeek, Qwen3, Kimi, GLM, MiniMax, MiMo), these get the same full tool budget as the other cloud providers (mistral/openai/google), not litellm/ollama's stripped-down local-model treatment. - `tools.opencode_model()` (src/tools/index.lex) reads OPENCODE_MODEL, mirroring the existing litellm_model() pattern. - Wired into src/server/session.lex's `pick_agent` and src/tui/main.lex's `select_provider_tag`/help text as a new `--opencode` flag. - litellm/config.yaml + litellm/docker-compose.yml: lex-code had no bundled proxy config at all (the README referenced a "litellm_config.yaml at project root" that didn't exist). Copied from lex-loom's own litellm/ directory — same model list including the OpenCode Go entries (DeepSeek/Qwen3/Kimi/GLM/MiniMax/MiMo) — so both repos can point at one shared proxy instance, and `--litellm` now works out of the box. - README: new "OpenCode Go plan" section, provider table row, and LiteLLM section pointing at the bundled config instead of a nonexistent file. Housekeeping (bringing this repo to the same convention as lex-agent/lex-soft/lex-loom/lex-memory): - lex.toml: bump the `lex` version pin from 0.9.7 to 0.10.9 (CI itself already installs 0.10.7; the rest of the ecosystem is on 0.10.9), add `license = "EUPL-1.2"` and a description. - LICENSE file (matches lex-lang/lex-os/lex-soft/lex-loom/lex-agent/ lex-memory's EUPL-1.2 notice-and-pointer form). - AGENTS.md + CLAUDE.md: this repo had neither, despite being the project that *builds* the coding-agent convention for everyone else. Documents the two-call-sites-per-provider pattern (agents/*.lex + session.lex's pick_agent), why there's no tests/ directory (CI gates on per-file lex check + the manifesto demos), and the local-model tool-budget curation. - README: License section, v0.6 roadmap entry. Verification: - lex pkg install: resolves cleanly (12 packages). - lex check on every tracked src/*.lex file (matches CI's own loop): all ok. - lex fmt --check src/: all 76 files formatted. - Manifesto demos (CI's actual regression gate, no tests/ dir exists): manifesto_parallel.lex passes, its negative twin is correctly rejected, manifesto_semantic_diff/run.sh validates. - Live smoke test: two lex run invocations confirm build_agent.opencode_agent() constructs and session.pick_agent(Build, "opencode") dispatches to it correctly (script not part of this diff). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY --- AGENTS.md | 74 +++++++++ CLAUDE.md | 22 +++ LICENSE | 23 +++ README.md | 52 ++++++- lex.toml | 8 +- litellm/config.yaml | 302 +++++++++++++++++++++++++++++++++++++ litellm/docker-compose.yml | 23 +++ src/agents/build.lex | 10 ++ src/agents/explore.lex | 5 + src/agents/plan.lex | 5 + src/agents/refactor.lex | 5 + src/agents/review.lex | 5 + src/agents/spec_agent.lex | 5 + src/agents/test_agent.lex | 5 + src/server/session.lex | 9 ++ src/tools/index.lex | 12 ++ src/tui/main.lex | 8 +- 17 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 litellm/config.yaml create mode 100644 litellm/docker-compose.yml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..52f3028 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# 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`), and the ACP server + (`src/server/acp.lex`) are three transports over the same + `Session`/`run_turn_with_provider`. Extend `session.lex`, don't + duplicate turn logic into a transport file. +- **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..1f88a12 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,13 +118,40 @@ 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 \ + 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 \ + 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 \ + 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 \ @@ -135,6 +163,11 @@ LITELLM_MODEL=qwen3-coder:30b \ lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time \ 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 \ + 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 \ @@ -142,9 +175,11 @@ LITELLM_MODEL=qwen3-coder:30b \ 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. @@ -406,6 +441,13 @@ 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) + +--- + +## License + +EUPL-1.2 — matches the rest of the lex ecosystem. --- diff --git a/lex.toml b/lex.toml index ced9807..eb938fc 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.9" +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" } 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/server/session.lex b/src/server/session.lex index d677835..2840927 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(), 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..ac731e1 100644 --- a/src/tui/main.lex +++ b/src/tui/main.lex @@ -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" + } } } } @@ -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") { From 89f33534ea566f66725381917c1b33fc649cc24b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:15:33 +0000 Subject: [PATCH 2/7] Fix CI break: migrate project_memory.lex to lex-memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on the previous commit failed: "module \"src/memory\" not found in package \"lex-agent\"". src/project_memory.lex imports lex-agent/src/memory, which was deleted from lex-agent in favor of the new lex-memory package (alpibrusl/lex-memory#1, alpibrusl/lex-agent#30) — a consumer this repo's own audit at extraction time didn't know about, since lex-code wasn't yet in scope. lex-code's lex.toml pins lex-agent with a loose git ref (no branch/tag), so CI picked up the deletion the moment it landed on lex-agent's main. Fix: add lex-memory as a direct dependency and swap the import, same as the migration already done for lex-soft/lex-loom. No behavior change — project_memory.lex's own API (open/close/store_*/recall_*) is untouched; only the underlying module moved. Verification: - lex pkg install: lex-memory resolves cleanly (13 packages). - lex check on every tracked src/*.lex file: all ok. - lex fmt --check src/: all 76 files formatted. - Manifesto demos: unaffected, still pass. - Live smoke test: two lex run invocations against a real file-backed SQLite db confirm store_convention (process 1) is correctly recalled via recall_for_prompt (process 2) through the new import. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY --- lex.toml | 1 + src/project_memory.lex | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lex.toml b/lex.toml index eb938fc..ee7ca1f 100644 --- a/lex.toml +++ b/lex.toml @@ -16,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/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 From b37150c52800157452442aaa823b0e3e97c5bf75 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:27:39 +0000 Subject: [PATCH 3/7] Bump pinned lex toolchain to v0.10.10 Picks up lex-lang v0.10.10 (released today): the JIT step-counter fix closing the --max-steps bypass (#465/#710), the new [approval] effect for human-in-the-loop host boundaries (#737), and 'vcs' added to lex test's default permissive effect policy (#736), among other changes since v0.10.9/v0.10.7 (the two versions this repo had drifted across between lex.toml and CI -- lex.toml said 0.10.9, ci.yml/ publish.yml still said 0.10.7). Verification (against the real v0.10.10 release binary, not just the version string): - lex pkg install: resolves cleanly (13 packages). - lex check on every tracked src/*.lex file: all ok. - lex fmt --check src/: all 76 files formatted. - Manifesto demos (this repo's actual CI regression gate, no tests/ dir exists): manifesto_parallel.lex passes, its negative twin is still correctly rejected, manifesto_semantic_diff/run.sh validates. No behavior differences observed from the 0.10.9 baseline. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 2 +- lex.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/lex.toml b/lex.toml index ee7ca1f..bc7e437 100644 --- a/lex.toml +++ b/lex.toml @@ -1,7 +1,7 @@ [package] name = "lex-code" version = "0.1.0" -lex = "0.10.9" +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." From 9a07a8d8794558a1e3092891041fc5fa90c8483e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:21:15 +0000 Subject: [PATCH 4/7] =?UTF-8?q?Add=20Agent=20Client=20Protocol=20(Zed)=20s?= =?UTF-8?q?erver=20=E2=80=94=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lex-code already has an "ACP" server (src/server/acp.lex), but that's BeeAI's unrelated REST-based Agent Communication Protocol. This adds support for Zed's Agent Client Protocol: JSON-RPC 2.0 over stdin/ stdout, NDJSON-framed, the standard Zed/JetBrains/Neovim/Emacs use to launch and drive any coding agent as a subprocess (opencode is one of the other agents already on the shared ACP Registry). New src/server/client_protocol.lex, Phase 1: initialize, session/new, session/prompt (streaming session/update notifications per step), session/close. Deliberately not yet implemented, documented in the file header rather than silently missing: - session/request_permission — today's permission_spec is a static filter at agent construction, not an interactive round-trip; wiring this changes *when* the check fires, not just adding a handler. - $/cancel_request — needs the turn running as a std.conc actor (the pattern src/server/multi_agent.lex already uses for --multi) so the stdin-read loop stays free to receive a cancel mid-turn. - client-mediated fs/*, terminal/*, auth/login — optional in ACP; lex-code's own read_file/write_file/bash tools and env-var provider keys already cover this ground directly. session.lex gains run_turn_streaming_with_provider(session, input, provider_tag, on_step) alongside the existing run_turn_with_provider, so all four transports (TUI, A2A, BeeAI ACP, this) still share one turn-handling implementation per this repo's own AGENTS.md convention, rather than duplicating loop logic into the new transport file. Note in the code: this is NOT token-level real-time streaming — lex-llm's run_loop_traced already runs the full loop to completion internally before returning its Iter, so on_step fires in a burst right after the blocking call returns, not interleaved with live generation. WIRE-FORMAT CAVEAT (documented in the file and the README): the exact session/update field shapes are a best-effort reconstruction from available ACP v2 schema documentation, not a byte-for-byte verified trace against a reference SDK — the JSON-RPC 2.0 envelope and NDJSON framing themselves were verified directly against the protocol's own GitHub repo and multiple independent sources. Verification: - lex check on every tracked src/*.lex file + the new file: all ok. - lex fmt --check src/: all 77 files formatted. - Manifesto demos (this repo's CI gate, no tests/ dir exists): all three still pass/fail as expected. - Isolated checks of every session/update variant's exact JSON shape (text chunk, tool-call begin/exec/result) via direct lex run calls. - Live end-to-end test: a real subprocess spawned exactly as an editor would, piped real NDJSON over stdin/stdout via a bash coprocess — initialize, session/new (real ephemeral session + random sessionId), unknown-method error (-32601), malformed-JSON error (-32700), session/prompt on an unknown sessionId (-32001), session/prompt on a real session with no provider key configured (graceful empty turn, no crash — matches lex-llm's existing provider-error-swallowing behavior, not new), session/close, and a clean exit after stdin closes. Empty stderr throughout — no non-protocol output on stdout. Not independently verified: a real LLM call end-to-end (no provider credentials available in this environment) and interop against an actual ACP client (Zed) for the field-shape caveat above. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY --- AGENTS.md | 17 +- README.md | 21 +++ src/server/client_protocol.lex | 273 +++++++++++++++++++++++++++++++++ src/server/session.lex | 37 +++++ 4 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 src/server/client_protocol.lex diff --git a/AGENTS.md b/AGENTS.md index 52f3028..94e2d1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,10 +62,19 @@ directory exists. 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`), and the ACP server - (`src/server/acp.lex`) are three transports over the same - `Session`/`run_turn_with_provider`. Extend `session.lex`, don't - duplicate turn logic into a transport file. + 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) diff --git a/README.md b/README.md index 1f88a12..373ed5f 100644 --- a/README.md +++ b/README.md @@ -279,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 \ + 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) @@ -442,6 +462,7 @@ authorised to use. - [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` --- diff --git a/src/server/client_protocol.lex b/src/server/client_protocol.lex new file mode 100644 index 0000000..e45d016 --- /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 \ +# 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] 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] 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] 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] Nil { + serve(map.new(), provider_tag_from_env()) +} + diff --git a/src/server/session.lex b/src/server/session.lex index 2840927..b248985 100644 --- a/src/server/session.lex +++ b/src/server/session.lex @@ -154,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] 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, From 0b1f66c2592c489cfe19b6af8488a3b42fb621c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:34:19 +0000 Subject: [PATCH 5/7] Propagate the new [approval] effect through the turn-execution call chain lex-lang v0.10.10 added a new [approval] effect (human-in-the-loop host boundary, lex-lang#737). lex-llm's run_loop/run_loop_traced picked it up immediately (their #41) -- both now require [..., approval]. Since lex-code's lex.toml pins lex-llm with a loose git ref, this landed on every open branch the moment it hit lex-llm's main, same mechanism as the earlier lex-agent/memory break -- except this one is a legitimate upstream feature, not a deletion, so the fix is pure propagation. Adds `approval` to every function's effect row that transitively calls ag.run_loop/run_loop_traced (directly, or via session.lex's run_turn*/run_turn_streaming_with_provider): session.lex's three turn functions, client_protocol.lex's handler chain (my own new file, this session), bootstrap/{hello,run}.lex, server/{acp,api* no-op below, multi_agent,web}.lex, and tui/main.lex's repl/run_once/multi_repl/ run_headless/main. Updated every --allow-effects example in the README and in main.lex's own doc comment to match, so copy-pasted commands still work at runtime. *api.lex needed NO changes -- lex check flagged it in the original sweep only because it transitively imports session.lex, which was still broken; once session.lex was fixed, api.lex passed as-is. Same false-positive risk applies to any file reached only by re-running `lex check` on a single entry point -- verify against the CURRENT state of each dependency before editing, not the first error message. One file is NOT fixed here and can't be fixed from lex-code alone: src/server/mcp_main.lex's make_handler is assigned into lex-agent's Skill.handle, whose effect row is fixed by lex-agent's own Skill type and does not include approval -- widening make_handler's row just moves the failure to that assignment site (effect rows unify by exact equality, not subtyping). This is a genuine incompatibility between two of lex-code's dependencies (lex-agent's Skill type vs lex-llm's now-approval-requiring run_loop), filed upstream as alpibrusl/lex-agent#32 with a suggested fix (add approval to Skill.handle's row). mcp_main.lex will stay red in CI until that lands -- flagging here rather than masking it. Verification (against the real v0.10.10 release binary): - lex check on every tracked src/*.lex file + client_protocol.lex: all ok except mcp_main.lex (the filed, upstream-blocked issue above). - lex fmt --check src/: all 77 files formatted. - Manifesto demos: all three still pass/fail as expected. - Live end-to-end re-test of the ACP server (real subprocess, real NDJSON over stdin/stdout via a bash coprocess) with the corrected --allow-effects list: initialize, session/new, session/close all round-trip correctly, clean exit, empty stderr. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY --- README.md | 18 +++++++++--------- src/bootstrap/hello.lex | 2 +- src/bootstrap/run.lex | 6 +++--- src/server/acp.lex | 2 +- src/server/client_protocol.lex | 10 +++++----- src/server/multi_agent.lex | 12 ++++++------ src/server/session.lex | 6 +++--- src/server/web.lex | 6 +++--- src/tui/main.lex | 12 ++++++------ 9 files changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 373ed5f..05bf08f 100644 --- a/README.md +++ b/README.md @@ -125,17 +125,17 @@ VLLM_MODEL=deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct \ ```sh # 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 \ +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 \ + 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 \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,approval \ src/tui/main.lex main -- --litellm "implement list.zip" ``` @@ -155,23 +155,23 @@ 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 \ + 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 ``` @@ -200,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 @@ -287,7 +287,7 @@ Emacs all speak it; opencode is one of the other agents already on the [ACP Regi ```sh LEX_CODE_PROVIDER=anthropic ANTHROPIC_API_KEY=… \ - lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,crypto,random \ + lex run --allow-effects env,io,net,llm,proc,sql,fs_write,time,crypto,random,approval \ src/server/client_protocol.lex main ``` 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/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/client_protocol.lex b/src/server/client_protocol.lex index e45d016..aa4573e 100644 --- a/src/server/client_protocol.lex +++ b/src/server/client_protocol.lex @@ -46,7 +46,7 @@ # # 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 \ +# 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 @@ -188,7 +188,7 @@ fn handle_session_new(id :: jv.Json, params :: jv.Json, registry :: Registry, pr } } -fn handle_session_prompt(id :: jv.Json, params :: jv.Json, registry :: Registry, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time] Registry { +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) { @@ -230,7 +230,7 @@ fn handle_session_close(id :: jv.Json, params :: jv.Json, registry :: Registry) } # ---- Dispatch + main loop ------------------------------------------------ -fn dispatch(line :: Str, registry :: Registry, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time, crypto, random, fs_write] Registry { +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")) @@ -256,7 +256,7 @@ fn dispatch(line :: Str, registry :: Registry, provider_tag :: Str) -> [env, net } } -fn serve(registry :: Registry, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time, crypto, random, fs_write] Nil { +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)) { @@ -267,7 +267,7 @@ fn serve(registry :: Registry, provider_tag :: Str) -> [env, net, llm, io, proc, } } -fn main() -> [env, net, llm, io, proc, sql, time, crypto, random, fs_write] Nil { +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/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 b248985..fb7aab2 100644 --- a/src/server/session.lex +++ b/src/server/session.lex @@ -135,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) @@ -164,7 +164,7 @@ fn run_turn_with_provider(session :: Session, user_input :: Str, provider_tag :: # 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] TurnResult { +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) diff --git a/src/server/web.lex b/src/server/web.lex index 9aa15c0..da24fb4 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) => { @@ -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/tui/main.lex b/src/tui/main.lex index ac731e1..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"), @@ -180,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) => { @@ -239,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) From 2d71cead891cc6cacad96d6d84c19dd5aacf749a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 18:03:50 +0000 Subject: [PATCH 6/7] chore: retrigger CI now that lex-agent#33 and lex-web#49 are merged Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY From f1821abfb9024da7d868b88fd56c8f43d7c3d840 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 18:38:26 +0000 Subject: [PATCH 7/7] Propagate the new [approval] effect into api.lex, mcp_main.lex, web.lex Completes the widening this repo's turn-execution chain already started: once lex-agent's Skill.handle (lex-agent#33), lex-web's route_effectful handler type (lex-web#49), and lex-mcp's Skill dispatch (lex-mcp#8) all required approval, the handlers this repo assigns into those fixed types needed it too -- handle_chat (api.lex), make_handler/main (mcp_main.lex), and the static-file route closure (web.lex). Verified against the real v0.10.10 release binary with every upstream fix (lex-agent, lex-web, lex-mcp, lex-agent-llm) resolved from main: lex check # all ok lex fmt --check src/ # 77 files formatted lex check examples/manifesto_parallel.lex # ok lex check examples/manifesto_parallel_bad.lex # correctly rejected bash examples/manifesto_semantic_diff/run.sh # validated --- src/server/api.lex | 2 +- src/server/mcp_main.lex | 6 +++--- src/server/web.lex | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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/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/web.lex b/src/server/web.lex index da24fb4..65e7cee 100644 --- a/src/server/web.lex +++ b/src/server/web.lex @@ -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(),