Skip to content
Merged

Dev #40

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This file is the top-level orientation map. Depth lives under [docs/](docs/).
| [oli_bot/chat.py](oli_bot/chat.py) | Textual TUI app (`OliBot`). Owns command handling, session UI, the `#command-suggestions` autocomplete `ListView`, and (when pooling is enabled) the "Active Sub-Agents" `Tree`. Tracks cumulative session token usage (persisted per session) and renders it in the `#status-bar`. Slash-command names come from the module-level `COMMANDS` tuple. |
| [oli_bot/api/](oli_bot/api/) | FastAPI package exposing the harness over an OpenAI-compatible REST API (`GET /v1/models`, `POST /v1/chat/completions` streaming + non-streaming, `GET /health`) plus a stateful `WS /v1/chat` WebSocket that relays every `AgentEvent` as a typed JSON envelope (`text_chunk`/`thinking`/`tool_call_executing`/`tool_call_result`/`assistant_response`/`usage`/`error`/`done`, plus `sub_agent_started`/`sub_agent_progress`/`sub_agent_completed` and `todo` frames) for real-time browser UIs. Sub-agent events are demuxed by `task_id`; `_wire_todo_relay()` pushes `builtin__todowrite` snapshots onto `mcp_manager.pending_todos`, drained by the socket loop. REST is stateless from the caller's POV; the WebSocket keeps per-connection history, backed by a server-persisted `ConversationStore` under the `"default"` server: turns are sent with an optional `session_id` (`{"content": "...", "session_id": "..."}`, `{"action": "clear", "session_id": "..."}` wipes it) and each completed turn is saved to disk, so browser and TUI share the same session files. Sessions are created/listed/loaded/renamed/deleted via `GET/POST /v1/sessions` and `GET/PUT/DELETE /v1/sessions/{id}`. A single process-private `Agent` is shared across requests and serialised with an `asyncio.Lock` held across `await` points (a `threading.RLock` would be per-thread reentrant and not serialize coroutines sharing the event loop); the WebSocket holds the lock for the duration of each run. Auto-approves permissions (no human), but offline/dry-run still apply. Also exposes `GET/POST /v1/mcp` plus `PUT/DELETE /v1/mcp/{name}` for the browser UI's MCP server configuration view (backed by `MCPClientManager` on `app.state.agent.mcp_manager`). `app.py` holds the `create_app()` factory + `init_state()`; `routers/` holds the route modules (`chat`, `ws`, `sessions`, `config`, `mcp`, `workspace`, `health`); `runner.py` implements the completion handoff, `harness.py` the agent construction + todo relay + `dispatch` handler, `session_service.py` the load/persist helpers, `errors.py` the OpenAI-style error handlers, `convert.py` the wire-message/event converters, `deps.py` the FastAPI dependencies, and `__main__.py` the `oli-server` entrypoint. [`oli_bot/api_server.py`](oli_bot/api_server.py) is a compat shim re-exporting the old single-module surface. See [docs/API_SERVER.md](docs/API_SERVER.md). |
| [oli_bot/agent.py](oli_bot/agent.py) | `Agent` — mode + system prompt owner; orchestrates the tool-calling loop and streams typed events (`TextChunk`, `ThinkingChunk`, `ToolCallChunk`, `ToolCallExecuting`, `ToolCallResult`, `StreamChunk`, `UsageEvent`, `Error`, `Done`). Aggregates per-call `UsageChunk`s from each backend round into a single per-run `UsageEvent`. Also hosts `sanitize_tool_history`, `_merge_usage`, `stream_sub_agent_run`, and `AgentPool` (built from `agents.yaml` when `--use-pool` is set; located via `$OLI_AGENTS_YAML`, the package dir, the repo root, the current working directory, or `~/.config/oli`). |
| [oli_bot/backends/](oli_bot/backends/) | Backend package — `ModelBackend` ABC, `OllamaBackend`, `OpenAIBackend`, `HuggingFaceBackend`, `TransformersBackend`, and the `create_model_backend()` factory. Also hosts the shared `_StreamingThinkParser` and per-backend message formatting (Ollama native `images`, OpenAI `image_url` or Bedrock-native blocks via `openai_vision_style`, textual placeholder for text-only backends). Every backend surfaces a trailing `UsageChunk`: exact counts from provider usage where available (OpenAI `usage`/`stream_options`, Ollama `prompt_eval_count`/`eval_count`, HF `usage`), else a `~chars/4` estimate via `estimate_tokens`. See [docs/BACKENDS.md](docs/BACKENDS.md). |
| [oli_bot/backends/](oli_bot/backends/) | Backend package — `ModelBackend` ABC, `OllamaBackend`, `OpenAIBackend`, `HuggingFaceBackend`, `TransformersBackend`, and the `create_model_backend()` factory. `OpenAIBackend` speaks both Chat Completions (default) and the Responses API, selected by the `OLI_OPENAI_RESPONSES_ENABLED` / `openai_responses_enabled` flag routed through `generate()` / `stream_generate()`. Also hosts the shared `_StreamingThinkParser` and per-backend message formatting (Ollama native `images`, OpenAI `image_url` or Bedrock-native blocks via `openai_vision_style`, Responses `input_text`/`input_image` items, textual placeholder for text-only backends). Every backend surfaces a trailing `UsageChunk`: exact counts from provider usage where available (OpenAI `usage`/`stream_options`, Responses `input_tokens`/`output_tokens`, Ollama `prompt_eval_count`/`eval_count`, HF `usage`), else a `~chars/4` estimate via `estimate_tokens`. See [docs/BACKENDS.md](docs/BACKENDS.md). |
| [oli_bot/screens/](oli_bot/screens/) | All `ModalScreen` subclasses: `PermissionScreen`, `ConfirmScreen`, `ModelPickerScreen`, `ServerListScreen`, `MCPSetupScreen`, `SessionListScreen`, `WorkspaceListScreen`, `SubAgentViewScreen`, `ConfigScreen`, `InputPromptScreen`, plus `taglines.py` / `todo_widget.py`. |
| [oli_bot/models.py](oli_bot/models.py) | Shared dataclasses: `Message`, `ToolCall`, `ModelResponse`, `HostConfig`, `MCPServerConfig`, `ProfileData`, `SubAgentRun`, `ImageAttachment`, `TodoItem` / `TodoListState`, plus `AgentEvent` variants and the `AgentRole` enum. Token accounting lives here too: `Usage` (prompt/completion/`estimated` flag), the per-call `UsageChunk` stream event, and the per-run `UsageEvent`. `Message.images` is in-memory only (dropped on session save); `ModelResponse.usage` is optionally set by backends. |
| [oli_bot/config.py](oli_bot/config.py) | `AppConfig` — `pydantic_settings.BaseSettings`. Env vars prefixed `OLI_`, plus `.env` support and `OLI_TRUNCATION_SMALL` / `_LARGE` aliases via `AliasChoices`. Module-level `configs = AppConfig()` singleton. See [docs/CONFIGURE.md](docs/CONFIGURE.md). |
Expand Down
21 changes: 14 additions & 7 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,20 @@ Multiple Ollama servers can be configured via `/servers` and persisted to `ollam

Works with the OpenAI API or any compatible endpoint (Azure, local proxies, etc.).

| Setting | Default | Env var |
| --------------------- | --------------------------- | ------------------------- |
| `openai_api_key` | `""` | `OLI_OPENAI_API_KEY` |
| `openai_base_url` | `https://api.openai.com/v1` | `OLI_OPENAI_BASE_URL` |
| `openai_model` | `gpt-4o` | `OLI_OPENAI_MODEL` |
| `openai_small_model` | `gpt-4o-mini` | `OLI_OPENAI_SMALL_MODEL` |
| `openai_vision_style` | `openai` | `OLI_OPENAI_VISION_STYLE` |
| Setting | Default | Env var |
| -------------------------- | --------------------------- | ----------------------------- |
| `openai_api_key` | `""` | `OLI_OPENAI_API_KEY` |
| `openai_base_url` | `https://api.openai.com/v1` | `OLI_OPENAI_BASE_URL` |
| `openai_model` | `gpt-4o` | `OLI_OPENAI_MODEL` |
| `openai_small_model` | `gpt-4o-mini` | `OLI_OPENAI_SMALL_MODEL` |
| `openai_vision_style` | `openai` | `OLI_OPENAI_VISION_STYLE` |
| `openai_responses_enabled` | `false` | `OLI_OPENAI_RESPONSES_ENABLED` |

By default the backend targets Chat Completions (`POST /v1/chat/completions`). Set
`openai_responses_enabled: true` (or `OLI_OPENAI_RESPONSES_ENABLED=true`) to use
the Responses API (`POST /v1/responses`) instead; tool calling, usage accounting,
and streaming are supported on both paths. Providers that only expose one of the
two wire formats must be configured with the matching flag.

`openai_vision_style` controls how the `view_image` tool serializes attachments for the OpenAI backend:

Expand Down
1 change: 1 addition & 0 deletions docs/CONFIGURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ If `~/.config/oli/settings.json` does not exist, it is auto-created on first loa
| `openai_model` | `gpt-4o` | `OLI_OPENAI_MODEL` | OpenAI large model |
| `openai_small_model` | `gpt-4o-mini` | `OLI_OPENAI_SMALL_MODEL` | OpenAI small model |
| `openai_vision_style` | `openai` | `OLI_OPENAI_VISION_STYLE` | Vision content-block style: `openai` (default `image_url`) or `bedrock` (Bedrock-native `image` blocks for Kong/LiteLLM proxies fronting Bedrock) |
| `openai_responses_enabled` | `false` | `OLI_OPENAI_RESPONSES_ENABLED` | Use the OpenAI Responses API (`/v1/responses`) instead of Chat Completions for the OpenAI backend |
| `ollama_base_url` | `http://localhost:11434` | `OLI_OLLAMA_BASE_URL` | Ollama server URL |
| `ollama_model` | `ollama` | `OLI_OLLAMA_MODEL` | Ollama large model |
| `ollama_small_model` | `""` | `OLI_OLLAMA_SMALL_MODEL` | Ollama small model |
Expand Down
2 changes: 1 addition & 1 deletion oli_bot/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
Importing this package has no side effects: the FastAPI app is built lazily via
``create_app()`` and agent/backend/MCP state is only constructed by ``init_state``
(called from the lifespan and from ``main()``).
"""
"""
2 changes: 1 addition & 1 deletion oli_bot/api/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@ def main() -> None:


if __name__ == "__main__":
main()
main()
2 changes: 1 addition & 1 deletion oli_bot/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,4 @@ def create_app() -> FastAPI:
workspace.router,
):
app.include_router(router)
return app
return app
2 changes: 1 addition & 1 deletion oli_bot/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

# Sessions are namespaced per "server". The browser shares the TUI's store by
# using the same default namespace the TUI falls back to when no server is set.
SESSION_SERVER = "default"
SESSION_SERVER = "default"
2 changes: 1 addition & 1 deletion oli_bot/api/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,4 @@ def _event_to_frame(event: "AgentEvent") -> Dict[str, Any]:
},
}
logger.warning("Unknown agent event in websocket relay: %r", event)
return {"type": "unknown", "data": {"event": repr(event)}}
return {"type": "unknown", "data": {"event": repr(event)}}
2 changes: 1 addition & 1 deletion oli_bot/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,4 @@ def get_ws_agent(websocket: WebSocket) -> Agent:


def get_ws_lock(websocket: WebSocket) -> Any:
return websocket.app.state.lock
return websocket.app.state.lock
2 changes: 1 addition & 1 deletion oli_bot/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ async def _http_error_handler(request: Request, exc: HTTPException) -> JSONRespo

def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(AgentError, _agent_error_handler)
app.add_exception_handler(HTTPException, _http_error_handler)
app.add_exception_handler(HTTPException, _http_error_handler)
6 changes: 2 additions & 4 deletions oli_bot/api/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,7 @@ async def _dispatch_tasks(tasks: List[dict]) -> str:
return "Error: dispatch called with no tasks"

available_tools = await agent.mcp_manager.get_available_tools()
sub_tools = [
t for t in available_tools if t.get("name") != "builtin__dispatch"
]
sub_tools = [t for t in available_tools if t.get("name") != "builtin__dispatch"]

now = datetime.now(timezone.utc).isoformat()
runs: List[SubAgentRun] = []
Expand All @@ -137,4 +135,4 @@ async def _dispatch_tasks(tasks: List[dict]) -> str:
event_sink=agent.mcp_manager.sub_agent_queue,
)

return _dispatch_tasks
return _dispatch_tasks
2 changes: 1 addition & 1 deletion oli_bot/api/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"""FastAPI routers for the API server package."""
"""FastAPI routers for the API server package."""
2 changes: 1 addition & 1 deletion oli_bot/api/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ async def chat_completions(
}
],
"usage": _usage(completion_text),
}
}
2 changes: 1 addition & 1 deletion oli_bot/api/routers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,4 @@ async def update_config(request: Request, flat: Dict[str, Any]) -> Any:
raise HTTPException(status_code=422, detail=f"Invalid config: {e}")
manager.save(settings)
request.app.state.config = config
return _nested_to_flat(settings)
return _nested_to_flat(settings)
2 changes: 1 addition & 1 deletion oli_bot/api/routers/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@

@router.get("/health")
async def health() -> Dict[str, str]:
return {"status": "ok"}
return {"status": "ok"}
6 changes: 2 additions & 4 deletions oli_bot/api/routers/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@

def _mcp_list(agent: Agent) -> List[Dict[str, Any]]:
"""Snapshot the current MCP server configs as a JSON-safe list."""
return [
dataclasses.asdict(cfg) for cfg in agent.mcp_manager.list_servers()
]
return [dataclasses.asdict(cfg) for cfg in agent.mcp_manager.list_servers()]


def _validate_mcp_config(cfg: MCPServerConfig) -> Optional[str]:
Expand Down Expand Up @@ -98,4 +96,4 @@ async def remove_mcp_server(
agent.mcp_manager.remove_server(name)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _mcp_list(agent)
return _mcp_list(agent)
2 changes: 1 addition & 1 deletion oli_bot/api/routers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@ async def delete_session(
"""Delete a session, returning 404 when it does not exist."""
if not store.delete_session(SESSION_SERVER, session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"deleted": session_id}
return {"deleted": session_id}
14 changes: 4 additions & 10 deletions oli_bot/api/routers/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,9 @@ async def set_workspace(
try:
path = Path(path_str).expanduser().resolve()
except (OSError, RuntimeError):
raise HTTPException(
status_code=422, detail=f"Invalid path: {path_str}"
)
raise HTTPException(status_code=422, detail=f"Invalid path: {path_str}")
if not path.is_dir():
raise HTTPException(
status_code=422, detail=f"Not a valid directory: {path}"
)
raise HTTPException(status_code=422, detail=f"Not a valid directory: {path}")
session = agent._session
session.workspace = path
session._session_grants.clear()
Expand Down Expand Up @@ -117,11 +113,9 @@ async def list_fs_directory(path: str = "/") -> Any:
try:
entries = _list_dir(resolved)
except PermissionError:
raise HTTPException(
status_code=403, detail=f"Permission denied: {resolved}"
)
raise HTTPException(status_code=403, detail=f"Permission denied: {resolved}")
return {
"path": str(resolved),
"sensitive": is_sensitive_path(resolved),
"entries": entries,
}
}
Loading
Loading