|
| 1 | +# Language Model Provider |
| 2 | + |
| 3 | +> **Experimental:** This feature is disabled by default. Set `symposium.enableExperimentalLM: true` in VS Code settings to enable it. |
| 4 | +
|
| 5 | +This chapter describes the architecture for exposing ACP agents as VS Code Language Models via the `LanguageModelChatProvider` API (introduced in VS Code 1.104). This allows ACP agents to appear in VS Code's model picker and be used by any extension that consumes the Language Model API. |
| 6 | + |
| 7 | +## Current Status |
| 8 | + |
| 9 | +The Language Model Provider is experimental and may not be the right approach for Symposium. |
| 10 | + |
| 11 | +**What works:** |
| 12 | +- Basic message flow between VS Code LM API and ACP agents |
| 13 | +- Session management with committed/provisional history model |
| 14 | +- Tool bridging architecture (both directions) |
| 15 | + |
| 16 | +**Known issues:** |
| 17 | +- Tool invocation fails when multiple VS Code-provided tools are bridged to the agent. A single isolated tool works correctly, but when multiple tools are available, the model doesn't invoke them properly. The root cause is not yet understood. |
| 18 | + |
| 19 | +**Open question:** VS Code LM consumers (like GitHub Copilot) inject their own context into requests - project details, file contents, editor state, etc. ACP agents like Claude Code also inject their own context. When both layers add context, they may "fight" each other, confusing the model. The LM API may be better suited for raw model access rather than wrapping agents that have their own context management. |
| 20 | + |
| 21 | +## Overview |
| 22 | + |
| 23 | +The Language Model Provider bridges VS Code's stateless Language Model API to ACP's stateful session model. When users select "Symposium" in the model picker, requests are routed through Symposium to the configured ACP agent. |
| 24 | + |
| 25 | +``` |
| 26 | +┌─────────────────────────────────────────────────────────────────┐ |
| 27 | +│ VS Code │ |
| 28 | +│ ┌───────────────────────────────────────────────────────────┐ │ |
| 29 | +│ │ Language Model Consumer │ │ |
| 30 | +│ │ (Copilot, other extensions, etc.) │ │ |
| 31 | +│ └─────────────────────────┬─────────────────────────────────┘ │ |
| 32 | +│ │ │ |
| 33 | +│ ▼ │ |
| 34 | +│ ┌───────────────────────────────────────────────────────────┐ │ |
| 35 | +│ │ LanguageModelChatProvider (TypeScript) │ │ |
| 36 | +│ │ │ │ |
| 37 | +│ │ - Thin adapter layer │ │ |
| 38 | +│ │ - Serializes VS Code API calls to JSON-RPC │ │ |
| 39 | +│ │ - Forwards to Rust process │ │ |
| 40 | +│ │ - Deserializes responses, streams back via progress │ │ |
| 41 | +│ └─────────────────────────┬─────────────────────────────────┘ │ |
| 42 | +└────────────────────────────┼────────────────────────────────────┘ |
| 43 | + │ JSON-RPC (stdio) |
| 44 | + ▼ |
| 45 | +┌─────────────────────────────────────────────────────────────────┐ |
| 46 | +│ symposium-acp-agent vscodelm │ |
| 47 | +│ │ |
| 48 | +│ - Receives serialized VS Code LM API calls │ |
| 49 | +│ - Manages session state │ |
| 50 | +│ - Routes to ACP agent (or Eliza for prototype) │ |
| 51 | +│ - Streams responses back │ |
| 52 | +└─────────────────────────────────────────────────────────────────┘ |
| 53 | +``` |
| 54 | + |
| 55 | +## Design Decisions |
| 56 | + |
| 57 | +### TypeScript/Rust Split |
| 58 | + |
| 59 | +The TypeScript extension is a thin adapter: |
| 60 | +- Registers as `LanguageModelChatProvider` |
| 61 | +- Serializes `provideLanguageModelChatResponse` calls to JSON-RPC |
| 62 | +- Sends to Rust process over stdio |
| 63 | +- Deserializes responses and streams back via `progress` callback |
| 64 | + |
| 65 | +The Rust process handles all logic: |
| 66 | +- Session management |
| 67 | +- Message history tracking |
| 68 | +- ACP protocol (future) |
| 69 | +- Response streaming |
| 70 | + |
| 71 | +This keeps the interesting logic in Rust where it's testable and maintainable. |
| 72 | + |
| 73 | +### Session Management |
| 74 | + |
| 75 | +VS Code's Language Model API is stateless: each request includes the full message history. ACP sessions are stateful. The Rust backend bridges this gap using a **History Actor** that tracks session state. |
| 76 | + |
| 77 | +#### Architecture |
| 78 | + |
| 79 | +```mermaid |
| 80 | +graph LR |
| 81 | + VSCode[VS Code] <--> HA[History Actor] |
| 82 | + HA <--> SA[Session Actor] |
| 83 | + SA <--> Agent[ACP Agent] |
| 84 | +``` |
| 85 | + |
| 86 | +- **History Actor**: Receives requests from VS Code, tracks message history, identifies new messages |
| 87 | +- **Session Actor**: Manages the ACP agent connection, handles streaming responses |
| 88 | + |
| 89 | +#### Committed and Provisional History |
| 90 | + |
| 91 | +The History Actor maintains two pieces of state: |
| 92 | + |
| 93 | +- **Committed**: Complete `(User, Assistant)*` message pairs that VS Code has acknowledged. Always ends with an assistant message (or is empty). |
| 94 | +- **Provisional**: The current in-flight exchange: one user message `U` and the assistant response parts `A` we've sent so far (possibly empty). |
| 95 | + |
| 96 | +#### Commit Flow |
| 97 | + |
| 98 | +When we receive a new request, we compare its history against `committed + provisional`: |
| 99 | + |
| 100 | +```mermaid |
| 101 | +sequenceDiagram |
| 102 | + participant VSCode as VS Code |
| 103 | + participant HA as History Actor |
| 104 | + participant SA as Session Actor |
| 105 | +
|
| 106 | + Note over HA: committed = [], provisional = (U1, []) |
| 107 | + |
| 108 | + SA->>HA: stream parts P1, P2, P3 |
| 109 | + Note over HA: provisional = (U1, [P1, P2, P3]) |
| 110 | + HA->>VSCode: stream P1, P2, P3 |
| 111 | + |
| 112 | + SA->>HA: done streaming |
| 113 | + HA->>VSCode: response complete |
| 114 | + |
| 115 | + VSCode->>HA: new request with history [U1, A1, U2] |
| 116 | + Note over HA: matches committed + provisional + new user msg |
| 117 | + Note over HA: commit: committed = [U1, A1] |
| 118 | + Note over HA: provisional = (U2, []) |
| 119 | + HA->>SA: new_messages = [U2], canceled = false |
| 120 | +``` |
| 121 | + |
| 122 | +The new user message `U2` confirms that VS Code received and accepted our assistant response `A1`. We commit the exchange and start fresh with `U2`. |
| 123 | + |
| 124 | +#### Cancellation via History Mismatch |
| 125 | + |
| 126 | +If VS Code sends a request that doesn't include our provisional content, the provisional work was rejected: |
| 127 | + |
| 128 | +```mermaid |
| 129 | +sequenceDiagram |
| 130 | + participant VSCode as VS Code |
| 131 | + participant HA as History Actor |
| 132 | + participant SA as Session Actor |
| 133 | +
|
| 134 | + Note over HA: committed = [U1, A1], provisional = (U2, [P1, P2]) |
| 135 | + |
| 136 | + VSCode->>HA: new request with history [U1, A1, U3] |
| 137 | + Note over HA: doesn't match committed + provisional |
| 138 | + Note over HA: discard provisional |
| 139 | + Note over HA: provisional = (U3, []) |
| 140 | + HA->>SA: new_messages = [U3], canceled = true |
| 141 | + |
| 142 | + SA->>SA: cancel downstream agent |
| 143 | +``` |
| 144 | + |
| 145 | +This happens when: |
| 146 | +- User cancels the chat in VS Code |
| 147 | +- User rejects a tool confirmation |
| 148 | +- User sends a different message while we were responding |
| 149 | + |
| 150 | +The Session Actor receives `canceled = true` and propagates cancellation to the downstream ACP agent. |
| 151 | + |
| 152 | +### Agent Configuration |
| 153 | + |
| 154 | +The agent to use is specified per-request via the `agent` field in the JSON-RPC protocol. This is an `AgentDefinition` enum: |
| 155 | + |
| 156 | +```typescript |
| 157 | +type AgentDefinition = |
| 158 | + | { eliza: { deterministic?: boolean } } |
| 159 | + | { mcp_server: McpServerStdio }; |
| 160 | + |
| 161 | +interface McpServerStdio { |
| 162 | + name: string; |
| 163 | + command: string; |
| 164 | + args: string[]; |
| 165 | + env: Array<{ name: string; value: string }>; |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +The TypeScript extension reads the agent configuration from VS Code settings via the agent registry, resolves the distribution to get the actual command, and includes it in each request. The Rust backend dispatches based on the variant: |
| 170 | + |
| 171 | +- **`eliza`**: Uses the in-process Eliza chatbot (useful for testing) |
| 172 | +- **`mcp_server`**: Spawns an external ACP agent process and manages sessions |
| 173 | + |
| 174 | +## JSON-RPC Protocol |
| 175 | + |
| 176 | +The protocol between TypeScript and Rust mirrors the `LanguageModelChatProvider` interface. |
| 177 | + |
| 178 | +### Requests (TypeScript → Rust) |
| 179 | + |
| 180 | +**`lm/provideLanguageModelChatResponse`** |
| 181 | + |
| 182 | +Each request includes the agent configuration via the `agent` field, which is an `AgentDefinition` enum with two variants: |
| 183 | + |
| 184 | +**External ACP agent (mcp_server)**: |
| 185 | +```json |
| 186 | +{ |
| 187 | + "jsonrpc": "2.0", |
| 188 | + "id": 1, |
| 189 | + "method": "lm/provideLanguageModelChatResponse", |
| 190 | + "params": { |
| 191 | + "modelId": "symposium", |
| 192 | + "messages": [ |
| 193 | + { "role": "user", "content": [{ "type": "text", "value": "Hello" }] } |
| 194 | + ], |
| 195 | + "agent": { |
| 196 | + "mcp_server": { |
| 197 | + "name": "my-agent", |
| 198 | + "command": "/path/to/agent", |
| 199 | + "args": ["--flag"], |
| 200 | + "env": [{ "name": "KEY", "value": "value" }] |
| 201 | + } |
| 202 | + } |
| 203 | + } |
| 204 | +} |
| 205 | +``` |
| 206 | + |
| 207 | +**Built-in Eliza (for testing)**: |
| 208 | +```json |
| 209 | +{ |
| 210 | + "jsonrpc": "2.0", |
| 211 | + "id": 1, |
| 212 | + "method": "lm/provideLanguageModelChatResponse", |
| 213 | + "params": { |
| 214 | + "modelId": "symposium-eliza", |
| 215 | + "messages": [ |
| 216 | + { "role": "user", "content": [{ "type": "text", "value": "Hello" }] } |
| 217 | + ], |
| 218 | + "agent": { |
| 219 | + "eliza": { "deterministic": true } |
| 220 | + } |
| 221 | + } |
| 222 | +} |
| 223 | +``` |
| 224 | + |
| 225 | +The Rust backend dispatches based on the variant - spawning an external process for `mcp_server` or using the in-process Eliza for `eliza`. |
| 226 | + |
| 227 | +### Notifications (Rust → TypeScript) |
| 228 | + |
| 229 | +**`lm/responsePart`** - Streams response chunks |
| 230 | +```json |
| 231 | +{ |
| 232 | + "jsonrpc": "2.0", |
| 233 | + "method": "lm/responsePart", |
| 234 | + "params": { |
| 235 | + "requestId": 1, |
| 236 | + "part": { "type": "text", "value": "How " } |
| 237 | + } |
| 238 | +} |
| 239 | +``` |
| 240 | + |
| 241 | +**`lm/responseComplete`** - Signals end of response |
| 242 | +```json |
| 243 | +{ |
| 244 | + "jsonrpc": "2.0", |
| 245 | + "method": "lm/responseComplete", |
| 246 | + "params": { |
| 247 | + "requestId": 1 |
| 248 | + } |
| 249 | +} |
| 250 | +``` |
| 251 | + |
| 252 | +### Response |
| 253 | + |
| 254 | +After all parts are streamed, the request completes: |
| 255 | +```json |
| 256 | +{ |
| 257 | + "jsonrpc": "2.0", |
| 258 | + "id": 1, |
| 259 | + "result": {} |
| 260 | +} |
| 261 | +``` |
| 262 | + |
| 263 | +## Implementation Status |
| 264 | + |
| 265 | +- [x] Rust: `vscodelm` subcommand in symposium-acp-agent |
| 266 | +- [x] Rust: JSON-RPC message parsing |
| 267 | +- [x] Rust: Eliza integration for testing |
| 268 | +- [x] Rust: Response streaming |
| 269 | +- [x] Rust: Configurable agent backend (McpServer support) |
| 270 | +- [x] Rust: Session actor with ACP session management |
| 271 | +- [x] TypeScript: LanguageModelChatProvider registration |
| 272 | +- [x] TypeScript: JSON-RPC client over stdio |
| 273 | +- [x] TypeScript: Progress callback integration |
| 274 | +- [x] TypeScript: Agent configuration from settings |
| 275 | +- [ ] End-to-end test with real ACP agent |
| 276 | + |
| 277 | +## Tool Bridging |
| 278 | + |
| 279 | +See [Language Model Tool Bridging](./lm-tool-bridging.md) for the design of how tools flow between VS Code and ACP agents. This covers: |
| 280 | + |
| 281 | +- VS Code-provided tools (shuttled to agent via synthetic MCP server) |
| 282 | +- Agent-internal tools (permission requests surfaced via `symposium-agent-action`) |
| 283 | +- Handle state management across requests |
| 284 | +- Cancellation and history matching |
| 285 | + |
| 286 | +## Future Work |
| 287 | + |
| 288 | +- Session caching with message history diffing |
| 289 | +- Token counting heuristics |
| 290 | +- Model metadata from agent capabilities |
0 commit comments