|
| 1 | +# OpenAI Agent |
| 2 | + |
| 3 | +ACP agent for [stdio Bus kernel](https://github.com/stdiobus/stdiobus) that bridges the Agent Client Protocol to any OpenAI Chat Completions API-compatible endpoint. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +The OpenAI Agent translates ACP protocol messages (`initialize`, `newSession`, `prompt`, `cancel`) into HTTP POST requests to `/chat/completions` with `stream: true`, parses the SSE response token-by-token, and streams results back as `agent_message_chunk` session updates. It maintains per-session conversation history for multi-turn dialogues. |
| 8 | + |
| 9 | +The agent is universal — it works with any endpoint implementing the OpenAI Chat Completions API: |
| 10 | + |
| 11 | +| Provider | Base URL | |
| 12 | +|----------|----------| |
| 13 | +| OpenAI | `https://api.openai.com/v1` (default) | |
| 14 | +| AWS Bedrock | `https://{region}.bedrock.amazonaws.com/openai/v1` | |
| 15 | +| Azure OpenAI | `https://{resource}.openai.azure.com/openai/deployments/{deployment}` | |
| 16 | +| Ollama | `http://localhost:11434/v1` | |
| 17 | +| vLLM | `http://localhost:8000/v1` | |
| 18 | +| LiteLLM | `http://localhost:4000/v1` | |
| 19 | + |
| 20 | +## Architecture |
| 21 | + |
| 22 | +``` |
| 23 | +stdio Bus kernel |
| 24 | + ↓ (NDJSON via stdin/stdout) |
| 25 | +OpenAI Agent |
| 26 | + ├── SessionIdRouter (sessionId mapping) |
| 27 | + ├── AgentSideConnection (ACP JSON-RPC 2.0) |
| 28 | + ├── OpenAIAgent (protocol handler) |
| 29 | + │ ├── SessionManager (conversation state) |
| 30 | + │ └── ChatCompletionsClient (HTTP + SSE) |
| 31 | + │ └── SSEParser (line-by-line parsing) |
| 32 | + └── fetch() → OpenAI-compatible API |
| 33 | +``` |
| 34 | + |
| 35 | +### Message Flow |
| 36 | + |
| 37 | +1. Registry Launcher spawns `openai-agent` as a child process with environment variables |
| 38 | +2. stdin receives NDJSON messages from stdio Bus kernel |
| 39 | +3. `SessionIdRouter` strips `sessionId` from incoming messages, restores it on outgoing |
| 40 | +4. `AgentSideConnection` + `ndJsonStream` handle JSON-RPC 2.0 framing |
| 41 | +5. `OpenAIAgent` dispatches to the appropriate handler (`initialize`, `newSession`, `prompt`, `cancel`) |
| 42 | +6. On `prompt`: ACP content blocks are converted to OpenAI messages, `ChatCompletionsClient` sends `POST {baseUrl}/chat/completions` with `stream: true` |
| 43 | +7. SSE chunks are parsed line-by-line; `delta.content` tokens are forwarded via `sessionUpdate()` |
| 44 | +8. On stream completion (`data: [DONE]`), the full response is saved to session history |
| 45 | + |
| 46 | +## Installation |
| 47 | + |
| 48 | +```bash |
| 49 | +cd workers-registry/openai-agent |
| 50 | +npm install |
| 51 | +npm run build |
| 52 | +``` |
| 53 | + |
| 54 | +## Usage |
| 55 | + |
| 56 | +### With stdio Bus kernel via Registry Launcher (recommended) |
| 57 | + |
| 58 | +The standard way to run the agent is through the Registry Launcher (`acp-registry` worker), which handles agent discovery, process management, and routing. |
| 59 | + |
| 60 | +**1. Create a custom agents file** (`openai-custom-agents.json`): |
| 61 | + |
| 62 | +```json |
| 63 | +{ |
| 64 | + "agents": [ |
| 65 | + { |
| 66 | + "id": "openai-gpt4o", |
| 67 | + "name": "OpenAI GPT-4o", |
| 68 | + "version": "1.0.0", |
| 69 | + "description": "OpenAI Chat Completions API agent via ACP protocol", |
| 70 | + "distribution": { |
| 71 | + "npx": { |
| 72 | + "package": "@stdiobus/workers-registry", |
| 73 | + "args": ["openai-agent"], |
| 74 | + "env": { |
| 75 | + "OPENAI_BASE_URL": "https://api.openai.com/v1", |
| 76 | + "OPENAI_MODEL": "gpt-4o" |
| 77 | + } |
| 78 | + } |
| 79 | + } |
| 80 | + } |
| 81 | + ] |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +**2. Add the API key** to `api-keys.json`: |
| 86 | + |
| 87 | +```json |
| 88 | +{ |
| 89 | + "OPENAI_API_KEY": "sk-..." |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +**3. Configure the stdio Bus pool** with `--custom-agents` pointing to your file: |
| 94 | + |
| 95 | +```json |
| 96 | +{ |
| 97 | + "pools": [{ |
| 98 | + "id": "acp-worker", |
| 99 | + "command": "npx", |
| 100 | + "args": [ |
| 101 | + "@stdiobus/workers-registry", "acp-registry", |
| 102 | + "--custom-agents", "./openai-custom-agents.json" |
| 103 | + ], |
| 104 | + "env": {}, |
| 105 | + "instances": 1 |
| 106 | + }] |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +**4. Start stdio Bus:** |
| 111 | + |
| 112 | +```bash |
| 113 | +./stdio_bus --config config.json --tcp 127.0.0.1:9000 |
| 114 | +``` |
| 115 | + |
| 116 | +The Registry Launcher will discover the `openai-gpt4o` agent from the custom agents file and launch it on demand when a client sends a message with `"agentId": "openai-gpt4o"`. |
| 117 | + |
| 118 | +Alternatively, instead of `--custom-agents` in args, you can set the environment variable: |
| 119 | + |
| 120 | +```bash |
| 121 | +export ACP_CUSTOM_AGENTS_PATH="./openai-custom-agents.json" |
| 122 | +``` |
| 123 | + |
| 124 | +### Standalone Mode (direct launch) |
| 125 | + |
| 126 | +You can also launch the worker directly via `npx` or `node` for testing: |
| 127 | + |
| 128 | +```bash |
| 129 | +export OPENAI_API_KEY="sk-..." |
| 130 | + |
| 131 | +# Via npx (published package) |
| 132 | +npx @stdiobus/workers-registry openai-agent |
| 133 | + |
| 134 | +# Via node (local build) |
| 135 | +node dist/index.js |
| 136 | +``` |
| 137 | + |
| 138 | +Test with a raw NDJSON message: |
| 139 | + |
| 140 | +```bash |
| 141 | +echo '{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"clientInfo":{"name":"test","version":"1.0"}}}' | \ |
| 142 | + npx @stdiobus/workers-registry openai-agent |
| 143 | +``` |
| 144 | + |
| 145 | +### With Ollama (local models) |
| 146 | + |
| 147 | +Create a custom agents file for Ollama: |
| 148 | + |
| 149 | +```json |
| 150 | +{ |
| 151 | + "agents": [ |
| 152 | + { |
| 153 | + "id": "ollama-llama3", |
| 154 | + "name": "Ollama Llama 3", |
| 155 | + "version": "1.0.0", |
| 156 | + "distribution": { |
| 157 | + "npx": { |
| 158 | + "package": "@stdiobus/workers-registry", |
| 159 | + "args": ["openai-agent"], |
| 160 | + "env": { |
| 161 | + "OPENAI_BASE_URL": "http://localhost:11434/v1", |
| 162 | + "OPENAI_MODEL": "llama3" |
| 163 | + } |
| 164 | + } |
| 165 | + } |
| 166 | + } |
| 167 | + ] |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +No API key is needed for Ollama. Or launch directly: |
| 172 | + |
| 173 | +```bash |
| 174 | +export OPENAI_BASE_URL="http://localhost:11434/v1" |
| 175 | +export OPENAI_MODEL="llama3" |
| 176 | +npx @stdiobus/workers-registry openai-agent |
| 177 | +``` |
| 178 | + |
| 179 | +### With AWS Bedrock |
| 180 | + |
| 181 | +```json |
| 182 | +{ |
| 183 | + "agents": [ |
| 184 | + { |
| 185 | + "id": "bedrock-claude", |
| 186 | + "name": "AWS Bedrock Claude", |
| 187 | + "version": "1.0.0", |
| 188 | + "distribution": { |
| 189 | + "npx": { |
| 190 | + "package": "@stdiobus/workers-registry", |
| 191 | + "args": ["openai-agent"], |
| 192 | + "env": { |
| 193 | + "OPENAI_BASE_URL": "https://us-east-1.bedrock.amazonaws.com/openai/v1", |
| 194 | + "OPENAI_MODEL": "anthropic.claude-3-sonnet-20240229-v1:0" |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + } |
| 199 | + ] |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +The `OPENAI_API_KEY` for Bedrock should be set via `api-keys.json`. |
| 204 | + |
| 205 | +## Configuration |
| 206 | + |
| 207 | +All configuration is via environment variables. No config files are needed. |
| 208 | + |
| 209 | +| Variable | Default | Description | |
| 210 | +|----------|---------|-------------| |
| 211 | +| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Base URL of the Chat Completions API endpoint | |
| 212 | +| `OPENAI_API_KEY` | `''` (empty) | API key for authentication. A warning is logged if unset (may be fine for local endpoints like Ollama) | |
| 213 | +| `OPENAI_MODEL` | `gpt-4o` | Model identifier passed in the `model` field of API requests | |
| 214 | +| `OPENAI_SYSTEM_PROMPT` | *(unset)* | Optional system prompt prepended to every conversation | |
| 215 | +| `OPENAI_MAX_TOKENS` | *(unset)* | Optional max tokens limit. Non-numeric values are ignored | |
| 216 | +| `OPENAI_TEMPERATURE` | *(unset)* | Optional temperature (float). Non-numeric values are ignored | |
| 217 | + |
| 218 | +## Protocol Support |
| 219 | + |
| 220 | +### ACP Methods |
| 221 | + |
| 222 | +| Method | Description | Status | |
| 223 | +|--------|-------------|--------| |
| 224 | +| `initialize` | Returns agent name, version, capabilities | ✅ Implemented | |
| 225 | +| `session/new` | Creates session with unique ID and empty history | ✅ Implemented | |
| 226 | +| `session/load` | Not supported (returns error) | ✅ Implemented | |
| 227 | +| `authenticate` | No-op (returns void) | ✅ Implemented | |
| 228 | +| `session/prompt` | Converts content → OpenAI messages, streams response | ✅ Implemented | |
| 229 | +| `cancel` | Aborts in-flight HTTP request via AbortController | ✅ Implemented | |
| 230 | + |
| 231 | +### Agent Capabilities |
| 232 | + |
| 233 | +```json |
| 234 | +{ |
| 235 | + "protocolVersion": "2025-03-26", |
| 236 | + "agentInfo": { "name": "openai-agent", "version": "1.0.0" }, |
| 237 | + "agentCapabilities": { |
| 238 | + "promptCapabilities": { "embeddedContext": true } |
| 239 | + }, |
| 240 | + "authMethods": [] |
| 241 | +} |
| 242 | +``` |
| 243 | + |
| 244 | +### Content Block Conversion |
| 245 | + |
| 246 | +The agent converts ACP content blocks to OpenAI user messages: |
| 247 | + |
| 248 | +| ACP Block Type | OpenAI Conversion | |
| 249 | +|----------------|-------------------| |
| 250 | +| `text` | Text content directly | |
| 251 | +| `resource_link` | `[Resource: {name}] {uri}` | |
| 252 | +| `resource` | `[Resource: {uri}]\n{text}` | |
| 253 | +| `image` | `[Image: {mimeType}]` | |
| 254 | + |
| 255 | +Multiple content blocks in a single prompt are joined with newlines into one user message. |
| 256 | + |
| 257 | +## Project Structure |
| 258 | + |
| 259 | +``` |
| 260 | +src/ |
| 261 | +├── index.ts # Entry point (stdin/stdout piping, SessionIdRouter, signal handlers) |
| 262 | +├── agent.ts # OpenAIAgent class implementing ACP Agent interface |
| 263 | +├── client.ts # ChatCompletionsClient (native fetch + SSE stream reading) |
| 264 | +├── sse-parser.ts # Stateless SSE line parser (data/done/skip classification) |
| 265 | +├── session.ts # Session class (history, AbortController, cancellation) |
| 266 | +├── session-manager.ts # SessionManager (create, get, cancel by ID) |
| 267 | +├── session-id-router.ts # SessionIdRouter (stdio Bus ↔ ACP sessionId mapping) |
| 268 | +├── config.ts # Configuration loader from process.env |
| 269 | +└── types.ts # Shared TypeScript type definitions |
| 270 | +``` |
| 271 | + |
| 272 | +## Error Handling |
| 273 | + |
| 274 | +All errors are delivered as `agent_message_chunk` session updates followed by `{ stopReason: 'end_turn' }`, so the client always receives a complete response cycle. |
| 275 | + |
| 276 | +| Condition | Error Message Pattern | |
| 277 | +|-----------|----------------------| |
| 278 | +| HTTP 401/403 | `Authentication error (HTTP {status}) calling {url}. Check your OPENAI_API_KEY.` | |
| 279 | +| HTTP 429 | `Rate limit exceeded (HTTP 429) calling {url}. Please retry later.` | |
| 280 | +| HTTP 500+ | `Server error (HTTP {status}) from {url}.` | |
| 281 | +| Network failure | `Network error connecting to {url}: {message}` | |
| 282 | +| Invalid SSE JSON | Logged to stderr, chunk skipped, stream continues | |
| 283 | +| Unknown sessionId | JSON-RPC error response via ACP SDK | |
| 284 | + |
| 285 | +## Graceful Shutdown |
| 286 | + |
| 287 | +- `SIGTERM`: Aborts all active HTTP requests, waits for `connection.closed`, exits with code 0 |
| 288 | +- `SIGINT`: Same behavior as SIGTERM |
| 289 | +- Uncaught exceptions: Logged to stderr, exits with code 1 |
| 290 | +- Unhandled rejections: Logged to stderr (process continues) |
| 291 | + |
| 292 | +## Development |
| 293 | + |
| 294 | +### Building |
| 295 | + |
| 296 | +```bash |
| 297 | +npm run build # Compile TypeScript to dist/ |
| 298 | +npm run clean # Remove dist/ |
| 299 | +``` |
| 300 | + |
| 301 | +### Testing |
| 302 | + |
| 303 | +```bash |
| 304 | +npm test # Run all tests (unit + property-based) |
| 305 | +``` |
| 306 | + |
| 307 | +The test suite includes 11 test files covering 5 unit test suites and 6 property-based test suites: |
| 308 | + |
| 309 | +**Unit tests** (`tests/*.test.ts`): |
| 310 | +- `config.test.ts` — default values, env var reading, numeric parsing, missing key warning |
| 311 | +- `session.test.ts` — session creation, history management, cancellation lifecycle |
| 312 | +- `sse-parser.test.ts` — SSE line parsing (data, done, skip, comments, invalid JSON) |
| 313 | +- `agent.test.ts` — initialize, newSession, loadSession, authenticate, prompt with mocked client |
| 314 | +- `client.test.ts` — HTTP error classification (401/403/429/500+), network errors, stream completion, cancellation |
| 315 | + |
| 316 | +**Property-based tests** (`tests/*.property.test.ts`), each running 100+ iterations with `fast-check`: |
| 317 | +- `config.property.test.ts` — configuration round-trip, numeric env var parsing |
| 318 | +- `session.property.test.ts` — session uniqueness, history order preservation, cancellation semantics |
| 319 | +- `sse-parser.property.test.ts` — SSE line classification, content round-trip, invalid JSON resilience |
| 320 | +- `conversion.property.test.ts` — content block conversion, request construction with history, request invariants |
| 321 | +- `error-handling.property.test.ts` — HTTP error classification across status code ranges |
| 322 | +- `agent.property.test.ts` — initialize response field validation |
| 323 | + |
| 324 | +### Key Design Decisions |
| 325 | + |
| 326 | +- **Zero HTTP dependencies**: Uses native `fetch()` (Node.js 20+) instead of axios/node-fetch |
| 327 | +- **Stateless SSE parser**: `parseLine()` is a pure function — no buffering state, easy to test and reason about |
| 328 | +- **Per-session AbortController**: Each prompt gets a fresh `AbortController` via `resetCancellation()`, so cancellation of one request doesn't affect the next |
| 329 | +- **Partial responses discarded on cancel**: When a request is cancelled, the incomplete assistant response is not saved to history, preventing corrupted conversation state |
| 330 | +- **All logging to stderr**: stdout is reserved exclusively for NDJSON protocol messages |
| 331 | + |
| 332 | +## Performance |
| 333 | + |
| 334 | +- Session creation: O(1) — UUID generation + Map insertion |
| 335 | +- Session lookup: O(1) — Map.get by sessionId |
| 336 | +- SSE parsing: O(n) per line — single-pass string operations |
| 337 | +- Memory: proportional to conversation history length per session (no persistence, no eviction) |
| 338 | + |
| 339 | +## License |
| 340 | + |
| 341 | +Apache License 2.0 |
| 342 | + |
| 343 | +Copyright (c) 2025–present Raman Marozau, Target Insight Function. |
| 344 | + |
| 345 | +## Resources |
| 346 | + |
| 347 | +- [stdio Bus kernel](https://github.com/stdiobus/stdiobus) — Core protocol and daemon |
| 348 | +- [ACP SDK](https://www.npmjs.com/package/@agentclientprotocol/sdk) — Official Agent Client Protocol SDK |
| 349 | +- [ACP Registry](https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json) — Available agents |
| 350 | +- [OpenAI Chat Completions API](https://platform.openai.com/docs/api-reference/chat) — API reference |
0 commit comments