feat: plugins and public api (#71)
This commit is contained in:
+23
-2
@@ -5,10 +5,15 @@ This guide explains how to add a **new engine runner** to Takopi.
|
|||||||
A *runner* is the adapter between an engine-specific CLI (Codex, Claude Code, …) and Takopi’s
|
A *runner* is the adapter between an engine-specific CLI (Codex, Claude Code, …) and Takopi’s
|
||||||
**normalized event model** (`StartedEvent`, `ActionEvent`, `CompletedEvent`).
|
**normalized event model** (`StartedEvent`, `ActionEvent`, `CompletedEvent`).
|
||||||
|
|
||||||
|
If you are building an external plugin package, read `docs/plugins.md` first.
|
||||||
|
|
||||||
Takopi is designed so that adding a runner usually means **adding one new module** under
|
Takopi is designed so that adding a runner usually means **adding one new module** under
|
||||||
`src/takopi/runners/` plus a small **msgspec schema** module under `src/takopi/schemas/`—
|
`src/takopi/runners/` plus a small **msgspec schema** module under `src/takopi/schemas/`—
|
||||||
no changes to the bridge, renderer, or CLI.
|
no changes to the bridge, renderer, or CLI.
|
||||||
|
|
||||||
|
When writing code intended for plugins, prefer importing from `takopi.api`
|
||||||
|
instead of internal modules.
|
||||||
|
|
||||||
The walkthrough below uses an **imaginary engine** named **Acme** (`acme`) and intentionally mirrors
|
The walkthrough below uses an **imaginary engine** named **Acme** (`acme`) and intentionally mirrors
|
||||||
the patterns used in `runners/claude.py`.
|
the patterns used in `runners/claude.py`.
|
||||||
|
|
||||||
@@ -74,6 +79,12 @@ Choose a stable engine id string. This string becomes:
|
|||||||
- The CLI subcommand (`takopi acme`)
|
- The CLI subcommand (`takopi acme`)
|
||||||
- The `ResumeToken.engine`
|
- The `ResumeToken.engine`
|
||||||
|
|
||||||
|
Engine ids must match the plugin ID regex:
|
||||||
|
|
||||||
|
```
|
||||||
|
^[a-z0-9_]{1,32}$
|
||||||
|
```
|
||||||
|
|
||||||
For Acme we’ll use:
|
For Acme we’ll use:
|
||||||
|
|
||||||
- Engine id: `"acme"`
|
- Engine id: `"acme"`
|
||||||
@@ -114,8 +125,18 @@ src/takopi/runners/
|
|||||||
acme.py # ← new
|
acme.py # ← new
|
||||||
```
|
```
|
||||||
|
|
||||||
Takopi discovers engines by importing modules in `takopi.runners` and looking for a
|
Takopi discovers engines via **entrypoints**. Every engine backend must be exposed
|
||||||
module-level `BACKEND: EngineBackend` (see `takopi.engines`).
|
as an entrypoint under `takopi.engine_backends`, and the entrypoint name must match
|
||||||
|
the backend id.
|
||||||
|
|
||||||
|
For in-repo engines, add an entrypoint in `pyproject.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.entry-points."takopi.engine_backends"]
|
||||||
|
acme = "takopi.runners.acme:BACKEND"
|
||||||
|
```
|
||||||
|
|
||||||
|
For external plugins, use your package’s `pyproject.toml` with the same group.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+56
-3
@@ -9,10 +9,19 @@ flowchart TB
|
|||||||
cli_desc["Entry point, config loading, lock file"]
|
cli_desc["Entry point, config loading, lock file"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
subgraph Plugins["Plugin Layer"]
|
||||||
|
entrypoints[plugins.py<br/>entrypoint discovery]
|
||||||
|
engines[engines.py]
|
||||||
|
transports[transports.py]
|
||||||
|
commands[commands.py]
|
||||||
|
api[api.py<br/>public plugin API]
|
||||||
|
end
|
||||||
|
|
||||||
subgraph Orchestration["Orchestration Layer"]
|
subgraph Orchestration["Orchestration Layer"]
|
||||||
router[AutoRouter<br/>router.py]
|
router[AutoRouter<br/>router.py]
|
||||||
scheduler[ThreadScheduler<br/>scheduler.py]
|
scheduler[ThreadScheduler<br/>scheduler.py]
|
||||||
projects[ProjectsConfig<br/>config.py]
|
projects[ProjectsConfig<br/>config.py]
|
||||||
|
runtime[TransportRuntime<br/>transport_runtime.py]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Bridge["Bridge Layer"]
|
subgraph Bridge["Bridge Layer"]
|
||||||
@@ -42,8 +51,18 @@ flowchart TB
|
|||||||
cli --> router
|
cli --> router
|
||||||
cli --> scheduler
|
cli --> scheduler
|
||||||
cli --> projects
|
cli --> projects
|
||||||
|
cli --> engines
|
||||||
|
cli --> transports
|
||||||
|
cli --> commands
|
||||||
|
engines --> entrypoints
|
||||||
|
transports --> entrypoints
|
||||||
|
commands --> entrypoints
|
||||||
|
router --> runtime
|
||||||
|
projects --> runtime
|
||||||
router --> tg_bridge
|
router --> tg_bridge
|
||||||
scheduler --> tg_bridge
|
scheduler --> tg_bridge
|
||||||
|
runtime --> tg_bridge
|
||||||
|
tg_bridge --> commands
|
||||||
tg_bridge --> runner_bridge
|
tg_bridge --> runner_bridge
|
||||||
runner_bridge --> runner_proto
|
runner_bridge --> runner_proto
|
||||||
runner_proto --> runners
|
runner_proto --> runners
|
||||||
@@ -59,6 +78,21 @@ flowchart TB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Plugin Architecture
|
||||||
|
|
||||||
|
Takopi discovers plugins via Python entrypoints and keeps loading lazy:
|
||||||
|
|
||||||
|
- **Engine backends** (`takopi.engine_backends`)
|
||||||
|
- **Transport backends** (`takopi.transport_backends`)
|
||||||
|
- **Command backends** (`takopi.command_backends`)
|
||||||
|
|
||||||
|
Entrypoint names become plugin IDs, are validated up front (reserved names, regex),
|
||||||
|
and are only loaded when needed. The public surface for plugin authors lives in
|
||||||
|
`takopi.api`, while transports and commands interact with core routing via
|
||||||
|
`TransportRuntime`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Domain Model
|
## Domain Model
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -120,10 +154,17 @@ sequenceDiagram
|
|||||||
participant RunnerBridge as runner_bridge.py
|
participant RunnerBridge as runner_bridge.py
|
||||||
participant Runner
|
participant Runner
|
||||||
participant AgentCLI as Agent CLI
|
participant AgentCLI as Agent CLI
|
||||||
|
participant Command as Command Plugin
|
||||||
|
|
||||||
User->>Telegram: Send message
|
User->>Telegram: Send message
|
||||||
Telegram->>Bridge: poll_incoming()
|
Telegram->>Bridge: poll_incoming()
|
||||||
|
|
||||||
|
Bridge->>Bridge: Parse slash command
|
||||||
|
alt Command plugin
|
||||||
|
Bridge->>Command: handle(ctx)
|
||||||
|
Command->>RunnerBridge: run_one/run_many (optional)
|
||||||
|
RunnerBridge->>Telegram: Send progress/final
|
||||||
|
else Default routing
|
||||||
Bridge->>Bridge: Parse directives<br/>(/engine, /project, @branch)
|
Bridge->>Bridge: Parse directives<br/>(/engine, /project, @branch)
|
||||||
Bridge->>Bridge: Extract resume token<br/>from reply
|
Bridge->>Bridge: Extract resume token<br/>from reply
|
||||||
Bridge->>Bridge: Resolve worktree<br/>(if @branch)
|
Bridge->>Bridge: Resolve worktree<br/>(if @branch)
|
||||||
@@ -133,6 +174,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
RunnerBridge->>Telegram: Send progress message
|
RunnerBridge->>Telegram: Send progress message
|
||||||
RunnerBridge->>Runner: run(prompt, resume)
|
RunnerBridge->>Runner: run(prompt, resume)
|
||||||
|
end
|
||||||
|
|
||||||
Runner->>AgentCLI: Spawn subprocess
|
Runner->>AgentCLI: Spawn subprocess
|
||||||
|
|
||||||
@@ -217,8 +259,14 @@ sequenceDiagram
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
cli[cli.py] --> config[config.py]
|
cli[cli.py] --> config[config.py]
|
||||||
cli --> engines[engines.py]
|
cli --> engines[engines.py]
|
||||||
|
cli --> transports[transports.py]
|
||||||
|
cli --> commands[commands.py]
|
||||||
cli --> lockfile[lockfile.py]
|
cli --> lockfile[lockfile.py]
|
||||||
|
|
||||||
|
engines --> plugins[plugins.py]
|
||||||
|
transports --> plugins
|
||||||
|
commands --> plugins
|
||||||
|
|
||||||
engines --> backends[backends.py]
|
engines --> backends[backends.py]
|
||||||
|
|
||||||
backends --> runners[runners/]
|
backends --> runners[runners/]
|
||||||
@@ -244,7 +292,10 @@ flowchart TD
|
|||||||
pi --> pi_s
|
pi --> pi_s
|
||||||
|
|
||||||
cli --> router[router.py]
|
cli --> router[router.py]
|
||||||
router --> tg_bridge[telegram/bridge.py]
|
tg_bridge --> runtime[transport_runtime.py]
|
||||||
|
runtime --> router
|
||||||
|
runtime --> config
|
||||||
|
tg_bridge --> commands
|
||||||
|
|
||||||
runner --> runner_bridge[runner_bridge.py]
|
runner --> runner_bridge[runner_bridge.py]
|
||||||
runner_bridge --> tg_bridge
|
runner_bridge --> tg_bridge
|
||||||
@@ -274,12 +325,13 @@ flowchart LR
|
|||||||
|
|
||||||
subgraph toml_contents["takopi.toml"]
|
subgraph toml_contents["takopi.toml"]
|
||||||
direction TB
|
direction TB
|
||||||
global["transport<br/>default_engine"]
|
global["transport<br/>default_engine<br/>default_project"]
|
||||||
telegram_cfg["[transports.telegram]<br/>bot_token = ...<br/>chat_id = ..."]
|
telegram_cfg["[transports.telegram]<br/>bot_token = ...<br/>chat_id = ..."]
|
||||||
|
plugins_cfg["[plugins]<br/>enabled = [\"...\"]"]
|
||||||
|
plugins_extra["[plugins.mycommand]<br/>setting = ..."]
|
||||||
claude_cfg["[claude]<br/>model = ..."]
|
claude_cfg["[claude]<br/>model = ..."]
|
||||||
codex_cfg["[codex]<br/>model = ..."]
|
codex_cfg["[codex]<br/>model = ..."]
|
||||||
projects_cfg["[projects.alias]<br/>path = ...<br/>worktrees_dir = ...<br/>default_engine = ..."]
|
projects_cfg["[projects.alias]<br/>path = ...<br/>worktrees_dir = ...<br/>default_engine = ..."]
|
||||||
default_proj["[projects]<br/>default = ..."]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
toml --> toml_contents
|
toml --> toml_contents
|
||||||
@@ -335,6 +387,7 @@ flowchart TD
|
|||||||
| Layer | Components | Responsibility |
|
| Layer | Components | Responsibility |
|
||||||
|-------|------------|----------------|
|
|-------|------------|----------------|
|
||||||
| **CLI** | `cli.py` | Entry point, config, lock |
|
| **CLI** | `cli.py` | Entry point, config, lock |
|
||||||
|
| **Plugins** | `plugins.py`, `engines.py`, `transports.py`, `commands.py`, `api.py` | Entrypoint discovery, plugin loading, public API boundary |
|
||||||
| **Orchestration** | `router.py`, `scheduler.py`, `config.py` | Engine selection, job queuing, project config |
|
| **Orchestration** | `router.py`, `scheduler.py`, `config.py` | Engine selection, job queuing, project config |
|
||||||
| **Bridge** | `telegram/bridge.py`, `runner_bridge.py` | Message handling, execution coordination |
|
| **Bridge** | `telegram/bridge.py`, `runner_bridge.py` | Message handling, execution coordination |
|
||||||
| **Runner** | `runner.py`, `runners/*.py`, `schemas/*.py` | Agent CLI subprocess, JSONL parsing, event translation |
|
| **Runner** | `runner.py`, `runners/*.py`, `schemas/*.py` | Agent CLI subprocess, JSONL parsing, event translation |
|
||||||
|
|||||||
+31
-3
@@ -77,9 +77,14 @@ Defines `Transport`, `MessageRef`, `RenderedMessage`, and `SendOptions`.
|
|||||||
|
|
||||||
Defines a renderer that converts `ProgressState` into `RenderedMessage` outputs.
|
Defines a renderer that converts `ProgressState` into `RenderedMessage` outputs.
|
||||||
|
|
||||||
### `transports.py` - Transport registry
|
### `transport_runtime.py` - Transport runtime facade
|
||||||
|
|
||||||
Defines the transport backend protocol, registry helpers, and built-in transport registration.
|
Provides the `TransportRuntime` helper used by transport backends to resolve
|
||||||
|
messages, select runners, and format context without depending on internal types.
|
||||||
|
|
||||||
|
### `transports.py` - Transport backend loading
|
||||||
|
|
||||||
|
Defines the transport backend protocol and entrypoint-backed loading helpers.
|
||||||
|
|
||||||
### `config_migrations.py` - Config migrations
|
### `config_migrations.py` - Config migrations
|
||||||
|
|
||||||
@@ -165,9 +170,32 @@ See `docs/transports/telegram.md` for outbox behavior, rate limiting, and retry
|
|||||||
Defines `EngineBackend`, `SetupIssue`, and the `EngineConfig` type used by
|
Defines `EngineBackend`, `SetupIssue`, and the `EngineConfig` type used by
|
||||||
runner modules.
|
runner modules.
|
||||||
|
|
||||||
|
### `plugins.py` - Entrypoint discovery
|
||||||
|
|
||||||
|
Centralizes plugin discovery and lazy loading:
|
||||||
|
|
||||||
|
- lists IDs without importing plugin modules
|
||||||
|
- loads a specific entrypoint on demand
|
||||||
|
- captures load errors for diagnostics
|
||||||
|
- filters by enabled list (distribution names)
|
||||||
|
|
||||||
|
### `commands.py` - Command backend loading
|
||||||
|
|
||||||
|
Defines the command backend protocol, command context/executor helpers, and
|
||||||
|
entrypoint-backed loading for slash-command plugins.
|
||||||
|
|
||||||
|
### `ids.py` - Plugin ID validation
|
||||||
|
|
||||||
|
Defines the shared ID regex used for plugin IDs and Telegram command names.
|
||||||
|
|
||||||
|
### `api.py` - Public plugin API
|
||||||
|
|
||||||
|
Re-exports the supported plugin surface from `takopi.api` (stable API boundary).
|
||||||
|
|
||||||
### `engines.py` - Engine backend discovery
|
### `engines.py` - Engine backend discovery
|
||||||
|
|
||||||
Auto-discovers runner modules in `takopi.runners` that export `BACKEND`.
|
Loads engine backends via entrypoints (`takopi.engine_backends`), with lazy loading
|
||||||
|
and enabled list support.
|
||||||
|
|
||||||
### `runners/` - Runner implementations
|
### `runners/` - Runner implementations
|
||||||
|
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
Takopi supports **entrypoint-based plugins** for:
|
||||||
|
|
||||||
|
- **Engine backends** (new runner implementations)
|
||||||
|
- **Transport backends** (new chat/command transports)
|
||||||
|
- **Command backends** (custom `/command` handlers)
|
||||||
|
|
||||||
|
Plugins are **discovered lazily**: Takopi lists IDs without importing plugin code,
|
||||||
|
and loads a plugin only when it is needed (or when you explicitly request it).
|
||||||
|
|
||||||
|
This keeps `takopi --help` fast and prevents broken plugins from bricking the CLI.
|
||||||
|
|
||||||
|
See `public-api.md` for the stable API surface you should depend on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entrypoint groups
|
||||||
|
|
||||||
|
Takopi uses two Python entrypoint groups:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.entry-points."takopi.engine_backends"]
|
||||||
|
myengine = "myengine.backend:BACKEND"
|
||||||
|
|
||||||
|
[project.entry-points."takopi.transport_backends"]
|
||||||
|
mytransport = "mytransport.backend:BACKEND"
|
||||||
|
|
||||||
|
[project.entry-points."takopi.command_backends"]
|
||||||
|
mycommand = "mycommand.backend:BACKEND"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- The entrypoint **name** is the plugin ID.
|
||||||
|
- The entrypoint value must resolve to a **backend object**:
|
||||||
|
- Engine backend -> `EngineBackend`
|
||||||
|
- Transport backend -> `TransportBackend`
|
||||||
|
- The backend object **must** have `id == entrypoint name`.
|
||||||
|
|
||||||
|
Takopi validates this at load time and will report errors via `takopi plugins --load`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ID rules
|
||||||
|
|
||||||
|
Plugin IDs are used in the CLI and (for engines/projects) in Telegram commands.
|
||||||
|
They must match:
|
||||||
|
|
||||||
|
```
|
||||||
|
^[a-z0-9_]{1,32}$
|
||||||
|
```
|
||||||
|
|
||||||
|
If an ID does not match, it is skipped and reported as an error.
|
||||||
|
|
||||||
|
**Reserved IDs (engines):**
|
||||||
|
|
||||||
|
- `cancel` (core chat command)
|
||||||
|
- `init`, `plugins` (CLI commands)
|
||||||
|
|
||||||
|
Engines using these IDs are skipped and reported as errors.
|
||||||
|
|
||||||
|
**Reserved IDs (commands):**
|
||||||
|
|
||||||
|
- `cancel`, `init`, `plugins`
|
||||||
|
- Any engine id or project alias (checked at runtime)
|
||||||
|
|
||||||
|
Command backends using reserved IDs are skipped and reported as errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enabling plugins
|
||||||
|
|
||||||
|
Takopi supports a simple enabled list to control which plugins are visible.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugins]
|
||||||
|
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
|
||||||
|
auto_install = false
|
||||||
|
```
|
||||||
|
|
||||||
|
- `enabled = []` (default) -> load all installed plugins.
|
||||||
|
- If `enabled` is non-empty, **only distributions with matching names** are visible.
|
||||||
|
- Distribution names are taken from package metadata (case-insensitive).
|
||||||
|
- If a plugin has no resolvable distribution name and an enabled list is set, it is hidden.
|
||||||
|
- `auto_install` is **reserved** and not implemented yet.
|
||||||
|
|
||||||
|
This enabled list affects:
|
||||||
|
|
||||||
|
- Engine subcommands registered in the CLI
|
||||||
|
- `takopi plugins` output
|
||||||
|
- Runtime resolution of engines/transports/commands
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discovering plugins
|
||||||
|
|
||||||
|
Use the CLI to inspect plugins:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
takopi plugins
|
||||||
|
takopi plugins --load
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- `takopi plugins` lists discovered entrypoints **without loading them**.
|
||||||
|
- `--load` loads each plugin to validate type and surface import errors.
|
||||||
|
- Errors are shown at the end, grouped by engine/transport and distribution.
|
||||||
|
- If `[plugins] enabled` is set, entries are still listed but marked `enabled`/`disabled`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Engine backend plugins
|
||||||
|
|
||||||
|
Engine plugins implement a runner for a new engine CLI and expose
|
||||||
|
an `EngineBackend` object.
|
||||||
|
|
||||||
|
Minimal example:
|
||||||
|
|
||||||
|
```py
|
||||||
|
# myengine/backend.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from takopi.api import EngineBackend, EngineConfig, Runner
|
||||||
|
|
||||||
|
def build_runner(config: EngineConfig, config_path: Path) -> Runner:
|
||||||
|
_ = config_path
|
||||||
|
# Parse config if needed; raise ConfigError for invalid config.
|
||||||
|
return MyEngineRunner(config)
|
||||||
|
|
||||||
|
BACKEND = EngineBackend(
|
||||||
|
id="myengine",
|
||||||
|
build_runner=build_runner,
|
||||||
|
cli_cmd="myengine",
|
||||||
|
install_cmd="pip install myengine",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`EngineConfig` is the raw config table (dict) from `takopi.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[myengine]
|
||||||
|
model = "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Read it with `settings.engine_config("myengine", config_path=...)` in Takopi,
|
||||||
|
or just consume the dict directly in your runner builder.
|
||||||
|
|
||||||
|
See `public-api.md` for the runner contract and helper classes like
|
||||||
|
`JsonlSubprocessRunner` and `EventFactory`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transport backend plugins
|
||||||
|
|
||||||
|
Transport plugins connect Takopi to new messaging systems (Slack, Discord, etc).
|
||||||
|
|
||||||
|
You must provide a `TransportBackend` object with:
|
||||||
|
|
||||||
|
- `id` and `description`
|
||||||
|
- `check_setup()` -> returns `SetupResult` (issues + config path)
|
||||||
|
- `interactive_setup()` -> optional interactive setup flow
|
||||||
|
- `lock_token()` -> token fingerprinting for config locks
|
||||||
|
- `build_and_run()` -> build transport and start the main loop
|
||||||
|
|
||||||
|
Minimal skeleton:
|
||||||
|
|
||||||
|
```py
|
||||||
|
# mytransport/backend.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from takopi.api import (
|
||||||
|
EngineBackend,
|
||||||
|
SetupResult,
|
||||||
|
TransportBackend,
|
||||||
|
TransportRuntime,
|
||||||
|
)
|
||||||
|
|
||||||
|
class MyTransportBackend:
|
||||||
|
id = "mytransport"
|
||||||
|
description = "MyTransport bot"
|
||||||
|
|
||||||
|
def check_setup(
|
||||||
|
self, engine_backend: EngineBackend, *, transport_override: str | None = None
|
||||||
|
) -> SetupResult:
|
||||||
|
_ = engine_backend, transport_override
|
||||||
|
return SetupResult(issues=[], config_path=Path("takopi.toml"))
|
||||||
|
|
||||||
|
def interactive_setup(self, *, force: bool) -> bool:
|
||||||
|
_ = force
|
||||||
|
return True
|
||||||
|
|
||||||
|
def lock_token(
|
||||||
|
self, *, transport_config: dict[str, object], config_path: Path
|
||||||
|
) -> str | None:
|
||||||
|
_ = transport_config, config_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_and_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
transport_config: dict[str, object],
|
||||||
|
config_path: Path,
|
||||||
|
runtime: TransportRuntime,
|
||||||
|
final_notify: bool,
|
||||||
|
default_engine_override: str | None,
|
||||||
|
) -> None:
|
||||||
|
_ = (
|
||||||
|
transport_config,
|
||||||
|
config_path,
|
||||||
|
runtime,
|
||||||
|
final_notify,
|
||||||
|
default_engine_override,
|
||||||
|
)
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
BACKEND = MyTransportBackend()
|
||||||
|
```
|
||||||
|
|
||||||
|
For most transports, you will want to call `handle_message()` from `takopi.api`
|
||||||
|
inside your message loop. That function implements progress updates, resume handling,
|
||||||
|
and cancellation semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command backend plugins
|
||||||
|
|
||||||
|
Command plugins add custom `/command` handlers. A command only runs when the
|
||||||
|
message starts with `/command` and does **not** collide with engine ids,
|
||||||
|
project aliases, or reserved command names.
|
||||||
|
|
||||||
|
Minimal example:
|
||||||
|
|
||||||
|
```py
|
||||||
|
# mycommand/backend.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from takopi.api import CommandContext, CommandResult, RunRequest
|
||||||
|
|
||||||
|
class MultiCommand:
|
||||||
|
id = "multi"
|
||||||
|
description = "run the prompt on every engine"
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult | None:
|
||||||
|
prompt = ctx.args_text.strip()
|
||||||
|
if not prompt:
|
||||||
|
return CommandResult(text="usage: /multi <prompt>")
|
||||||
|
requests = [
|
||||||
|
RunRequest(prompt=prompt, engine=engine)
|
||||||
|
for engine in ctx.runtime.available_engine_ids()
|
||||||
|
]
|
||||||
|
results = await ctx.executor.run_many(
|
||||||
|
requests,
|
||||||
|
mode="capture",
|
||||||
|
parallel=True,
|
||||||
|
)
|
||||||
|
blocks = []
|
||||||
|
for result in results:
|
||||||
|
text = result.message.text if result.message else "no output"
|
||||||
|
blocks.append(f"## {result.engine}\n{text}")
|
||||||
|
return CommandResult(text="\n\n".join(blocks))
|
||||||
|
|
||||||
|
BACKEND = MultiCommand()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command plugin configuration
|
||||||
|
|
||||||
|
Configure command plugins under `[plugins.<id>]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugins.multi]
|
||||||
|
engines = ["codex", "claude"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The parsed dict is available as `ctx.plugin_config` inside `handle()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioning & compatibility
|
||||||
|
|
||||||
|
Takopi exposes a **stable plugin API** via `takopi.api`.
|
||||||
|
|
||||||
|
- `TAKOPI_PLUGIN_API_VERSION = 1` is the current API version.
|
||||||
|
- Depend on a compatible Takopi version range, for example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
dependencies = ["takopi>=0.11,<0.12"]
|
||||||
|
```
|
||||||
|
|
||||||
|
When the plugin API changes, Takopi will bump the API version and document
|
||||||
|
any compatibility guidance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
|
||||||
|
- **Plugin missing from CLI**: check the enabled list in `[plugins] enabled`.
|
||||||
|
- **Plugin not listed**: verify entrypoint group and ID regex.
|
||||||
|
- **Load failures**: run `takopi plugins --load` and inspect errors.
|
||||||
|
- **ID mismatch**: ensure `BACKEND.id == entrypoint name`.
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# Public Plugin API
|
||||||
|
|
||||||
|
Takopi's **public plugin API** is exported from:
|
||||||
|
|
||||||
|
```
|
||||||
|
takopi.api
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything not imported from `takopi.api` should be considered **internal** and
|
||||||
|
subject to change. The API version is tracked by `TAKOPI_PLUGIN_API_VERSION`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- Current API version: `TAKOPI_PLUGIN_API_VERSION = 1`
|
||||||
|
- Plugins should pin to a compatible Takopi range, e.g.:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
dependencies = ["takopi>=0.11,<0.12"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exported symbols
|
||||||
|
|
||||||
|
### Engine backends and runners
|
||||||
|
|
||||||
|
| Symbol | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `EngineBackend` | Declares an engine backend (id + runner builder) |
|
||||||
|
| `EngineConfig` | Dict-based engine config table |
|
||||||
|
| `Runner` | Runner protocol |
|
||||||
|
| `BaseRunner` | Helper base class with resume locking |
|
||||||
|
| `JsonlSubprocessRunner` | Helper for JSONL-streaming CLIs |
|
||||||
|
| `EventFactory` | Helper for building takopi events |
|
||||||
|
|
||||||
|
### Transport backends
|
||||||
|
|
||||||
|
| Symbol | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `TransportBackend` | Transport backend protocol |
|
||||||
|
| `SetupIssue` | Setup issue for onboarding / validation |
|
||||||
|
| `SetupResult` | Setup issues + config path |
|
||||||
|
| `Transport` | Transport protocol (send/edit/delete) |
|
||||||
|
| `Presenter` | Renders progress to `RenderedMessage` |
|
||||||
|
| `RenderedMessage` | Rendered text + transport metadata |
|
||||||
|
| `SendOptions` | Reply/notify/replace flags |
|
||||||
|
| `MessageRef` | Transport-specific message reference |
|
||||||
|
| `TransportRuntime` | Transport runtime facade (routers/projects hidden) |
|
||||||
|
| `ResolvedMessage` | Parsed prompt + resume/context resolution |
|
||||||
|
| `ResolvedRunner` | Runner selection result |
|
||||||
|
|
||||||
|
### Command backends
|
||||||
|
|
||||||
|
| Symbol | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `CommandBackend` | Slash command plugin protocol |
|
||||||
|
| `CommandContext` | Context passed to a command handler |
|
||||||
|
| `CommandExecutor` | Helper to send messages or run engines |
|
||||||
|
| `CommandResult` | Simple response payload for a command |
|
||||||
|
| `RunRequest` | Engine run request used by commands |
|
||||||
|
| `RunResult` | Engine run result (captured output) |
|
||||||
|
| `RunMode` | `"emit"` (send) or `"capture"` (collect) |
|
||||||
|
|
||||||
|
### Core types and helpers
|
||||||
|
|
||||||
|
| Symbol | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `EngineId` | Engine id type alias |
|
||||||
|
| `ResumeToken` | Resume token (engine + value) |
|
||||||
|
| `StartedEvent` / `ActionEvent` / `CompletedEvent` | Core event types |
|
||||||
|
| `Action` | Action metadata for `ActionEvent` |
|
||||||
|
| `RunContext` | Project/branch context |
|
||||||
|
| `ConfigError` | Configuration error type |
|
||||||
|
| `DirectiveError` | Error raised when parsing directives |
|
||||||
|
| `RunnerUnavailableError` | Router error when a runner is unavailable |
|
||||||
|
|
||||||
|
### Bridge helpers (for transport plugins)
|
||||||
|
|
||||||
|
| Symbol | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `ExecBridgeConfig` | Transport + presenter config |
|
||||||
|
| `IncomingMessage` | Normalized incoming message |
|
||||||
|
| `RunningTask` / `RunningTasks` | Per-message run coordination |
|
||||||
|
| `handle_message()` | Core message handler used by transports |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runner contract (engine plugins)
|
||||||
|
|
||||||
|
Runners emit events in a strict sequence (see `tests/test_runner_contract.py`):
|
||||||
|
|
||||||
|
- Exactly **one** `StartedEvent`
|
||||||
|
- Exactly **one** `CompletedEvent`
|
||||||
|
- `CompletedEvent` is **last**
|
||||||
|
- `CompletedEvent.resume == StartedEvent.resume`
|
||||||
|
|
||||||
|
Action events are optional. The minimal valid run is:
|
||||||
|
|
||||||
|
```
|
||||||
|
StartedEvent -> CompletedEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resume tokens
|
||||||
|
|
||||||
|
Runners own the resume format:
|
||||||
|
|
||||||
|
- `format_resume(token)` returns a command line users can paste
|
||||||
|
- `extract_resume(text)` parses resume tokens from user text
|
||||||
|
- `is_resume_line(line)` lets Takopi strip resume lines before running
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EngineBackend
|
||||||
|
|
||||||
|
```py
|
||||||
|
EngineBackend(
|
||||||
|
id: str,
|
||||||
|
build_runner: Callable[[EngineConfig, Path], Runner],
|
||||||
|
cli_cmd: str | None = None,
|
||||||
|
install_cmd: str | None = None,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` must match the entrypoint name and the ID regex.
|
||||||
|
- `build_runner` should raise `ConfigError` for invalid config.
|
||||||
|
- `cli_cmd` is used to check whether the engine CLI is on `PATH`.
|
||||||
|
- `install_cmd` is surfaced in onboarding output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TransportBackend
|
||||||
|
|
||||||
|
```py
|
||||||
|
class TransportBackend(Protocol):
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
def check_setup(...) -> SetupResult: ...
|
||||||
|
def interactive_setup(self, *, force: bool) -> bool: ...
|
||||||
|
def lock_token(
|
||||||
|
self, *, transport_config: dict[str, object], config_path: Path
|
||||||
|
) -> str | None: ...
|
||||||
|
def build_and_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
transport_config: dict[str, object],
|
||||||
|
config_path: Path,
|
||||||
|
runtime: TransportRuntime,
|
||||||
|
final_notify: bool,
|
||||||
|
default_engine_override: str | None,
|
||||||
|
) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport backends are responsible for:
|
||||||
|
|
||||||
|
- Validating config and onboarding users (`check_setup`, `interactive_setup`)
|
||||||
|
- Providing a lock token so Takopi can prevent parallel runs
|
||||||
|
- Starting the transport loop in `build_and_run`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CommandBackend
|
||||||
|
|
||||||
|
```py
|
||||||
|
class CommandBackend(Protocol):
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Command handlers receive a `CommandContext` with:
|
||||||
|
|
||||||
|
- the raw command text and parsed args
|
||||||
|
- the original message + reply metadata
|
||||||
|
- `config_path` for the active `takopi.toml` (when known)
|
||||||
|
- `plugin_config` from `[plugins.<id>]` (dict, defaults to `{}`)
|
||||||
|
- `runtime` (engine/project resolution)
|
||||||
|
- `executor` (send messages or run engines)
|
||||||
|
|
||||||
|
Use `ctx.executor.run_one(...)` or `ctx.executor.run_many(...)` to reuse Takopi's
|
||||||
|
engine pipeline. Use `mode="capture"` to collect results and build a custom reply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TransportRuntime helpers
|
||||||
|
|
||||||
|
`TransportRuntime` keeps transports away from internal router/project types. Key helpers:
|
||||||
|
|
||||||
|
- `resolve_message(text, reply_text)` → `ResolvedMessage` (prompt, resume token, context)
|
||||||
|
- `resolve_engine(engine_override, context)` → `EngineId`
|
||||||
|
- `resolve_runner(resume_token, engine_override)` → `ResolvedRunner` (runner + availability info)
|
||||||
|
- `resolve_run_cwd(context)` → `Path | None` (raises `ConfigError` for project/worktree issues)
|
||||||
|
- `format_context_line(context)` → `str | None`
|
||||||
|
- `available_engine_ids()` / `missing_engine_ids()` / `engine_ids` / `default_engine`
|
||||||
|
- `project_aliases()`
|
||||||
|
- `config_path` (active config path when available)
|
||||||
|
- `plugin_config(plugin_id)` → `dict` from `[plugins.<id>]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bridge usage (transport plugins)
|
||||||
|
|
||||||
|
Most transports can delegate message handling to `handle_message()`. Use
|
||||||
|
`TransportRuntime` to resolve messages and select a runner:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from takopi.api import (
|
||||||
|
ExecBridgeConfig,
|
||||||
|
IncomingMessage,
|
||||||
|
RunningTask,
|
||||||
|
RunningTasks,
|
||||||
|
TransportRuntime,
|
||||||
|
handle_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_message(...):
|
||||||
|
resolved = runtime.resolve_message(text=text, reply_text=reply_text)
|
||||||
|
entry = runtime.resolve_runner(
|
||||||
|
resume_token=resolved.resume_token,
|
||||||
|
engine_override=resolved.engine_override,
|
||||||
|
)
|
||||||
|
context_line = runtime.format_context_line(resolved.context)
|
||||||
|
incoming = IncomingMessage(
|
||||||
|
channel_id=...,
|
||||||
|
message_id=...,
|
||||||
|
text=...,
|
||||||
|
reply_to=...,
|
||||||
|
)
|
||||||
|
await handle_message(
|
||||||
|
exec_cfg,
|
||||||
|
runner=entry.runner,
|
||||||
|
incoming=incoming,
|
||||||
|
resume_token=resolved.resume_token,
|
||||||
|
context=resolved.context,
|
||||||
|
context_line=context_line,
|
||||||
|
strip_resume_line=runtime.is_resume_line,
|
||||||
|
running_tasks=running_tasks,
|
||||||
|
on_thread_known=on_thread_known,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`handle_message()` implements:
|
||||||
|
|
||||||
|
- Progress updates and throttling
|
||||||
|
- Resume handling
|
||||||
|
- Cancellation propagation
|
||||||
|
- Final rendering
|
||||||
|
|
||||||
|
This keeps transport backends thin and consistent with core behavior.
|
||||||
@@ -35,6 +35,15 @@ Issues = "https://github.com/banteg/takopi/issues"
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
takopi = "takopi.cli:main"
|
takopi = "takopi.cli:main"
|
||||||
|
|
||||||
|
[project.entry-points."takopi.engine_backends"]
|
||||||
|
codex = "takopi.runners.codex:BACKEND"
|
||||||
|
claude = "takopi.runners.claude:BACKEND"
|
||||||
|
opencode = "takopi.runners.opencode:BACKEND"
|
||||||
|
pi = "takopi.runners.pi:BACKEND"
|
||||||
|
|
||||||
|
[project.entry-points."takopi.transport_backends"]
|
||||||
|
telegram = "takopi.telegram.backend:BACKEND"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.9.18,<0.10.0"]
|
requires = ["uv_build>=0.9.18,<0.10.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ takopi opencode
|
|||||||
takopi pi
|
takopi pi
|
||||||
```
|
```
|
||||||
|
|
||||||
list available transports (and override in a run):
|
list available plugins (engines/transports), and override in a run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
takopi transports
|
takopi plugins
|
||||||
takopi --transport telegram
|
takopi --transport telegram
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -145,6 +145,15 @@ default: progress is silent, final answer is sent as a new message so you receiv
|
|||||||
|
|
||||||
if you prefer no notifications, `--no-final-notify` edits the progress message into the final answer.
|
if you prefer no notifications, `--no-final-notify` edits the progress message into the final answer.
|
||||||
|
|
||||||
|
## plugins
|
||||||
|
|
||||||
|
Takopi supports entrypoint-based plugins for engines and transports.
|
||||||
|
|
||||||
|
See:
|
||||||
|
|
||||||
|
- `docs/plugins.md`
|
||||||
|
- `docs/public-api.md`
|
||||||
|
|
||||||
## notes
|
## notes
|
||||||
|
|
||||||
* the bot only responds to the configured `chat_id` (private or group)
|
* the bot only responds to the configured `chat_id` (private or group)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Stable public API for Takopi plugins."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .backends import EngineBackend, EngineConfig, SetupIssue
|
||||||
|
from .commands import (
|
||||||
|
CommandBackend,
|
||||||
|
CommandContext,
|
||||||
|
CommandExecutor,
|
||||||
|
CommandResult,
|
||||||
|
RunMode,
|
||||||
|
RunRequest,
|
||||||
|
RunResult,
|
||||||
|
)
|
||||||
|
from .config import ConfigError
|
||||||
|
from .context import RunContext
|
||||||
|
from .directives import DirectiveError
|
||||||
|
from .events import EventFactory
|
||||||
|
from .model import (
|
||||||
|
Action,
|
||||||
|
ActionEvent,
|
||||||
|
CompletedEvent,
|
||||||
|
EngineId,
|
||||||
|
ResumeToken,
|
||||||
|
StartedEvent,
|
||||||
|
)
|
||||||
|
from .presenter import Presenter
|
||||||
|
from .router import RunnerUnavailableError
|
||||||
|
from .runner import BaseRunner, JsonlSubprocessRunner, Runner
|
||||||
|
from .runner_bridge import (
|
||||||
|
ExecBridgeConfig,
|
||||||
|
IncomingMessage,
|
||||||
|
RunningTask,
|
||||||
|
RunningTasks,
|
||||||
|
handle_message,
|
||||||
|
)
|
||||||
|
from .transport import MessageRef, RenderedMessage, SendOptions, Transport
|
||||||
|
from .transport_runtime import ResolvedMessage, ResolvedRunner, TransportRuntime
|
||||||
|
from .transports import SetupResult, TransportBackend
|
||||||
|
|
||||||
|
TAKOPI_PLUGIN_API_VERSION = 1
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Action",
|
||||||
|
"ActionEvent",
|
||||||
|
"BaseRunner",
|
||||||
|
"CompletedEvent",
|
||||||
|
"ConfigError",
|
||||||
|
"CommandBackend",
|
||||||
|
"CommandContext",
|
||||||
|
"CommandExecutor",
|
||||||
|
"CommandResult",
|
||||||
|
"EngineBackend",
|
||||||
|
"EngineConfig",
|
||||||
|
"EngineId",
|
||||||
|
"ExecBridgeConfig",
|
||||||
|
"EventFactory",
|
||||||
|
"IncomingMessage",
|
||||||
|
"JsonlSubprocessRunner",
|
||||||
|
"MessageRef",
|
||||||
|
"DirectiveError",
|
||||||
|
"Presenter",
|
||||||
|
"RenderedMessage",
|
||||||
|
"ResumeToken",
|
||||||
|
"RunMode",
|
||||||
|
"RunRequest",
|
||||||
|
"RunResult",
|
||||||
|
"ResolvedMessage",
|
||||||
|
"ResolvedRunner",
|
||||||
|
"RunContext",
|
||||||
|
"Runner",
|
||||||
|
"RunnerUnavailableError",
|
||||||
|
"RunningTask",
|
||||||
|
"RunningTasks",
|
||||||
|
"SendOptions",
|
||||||
|
"SetupIssue",
|
||||||
|
"SetupResult",
|
||||||
|
"StartedEvent",
|
||||||
|
"TAKOPI_PLUGIN_API_VERSION",
|
||||||
|
"Transport",
|
||||||
|
"TransportBackend",
|
||||||
|
"TransportRuntime",
|
||||||
|
"handle_message",
|
||||||
|
]
|
||||||
+241
-47
@@ -4,6 +4,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from importlib.metadata import EntryPoint
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -12,7 +13,9 @@ from . import __version__
|
|||||||
from .backends import EngineBackend
|
from .backends import EngineBackend
|
||||||
from .config import ConfigError, load_or_init_config, write_config
|
from .config import ConfigError, load_or_init_config, write_config
|
||||||
from .config_migrations import migrate_config
|
from .config_migrations import migrate_config
|
||||||
from .engines import get_backend, list_backends
|
from .commands import get_command
|
||||||
|
from .engines import get_backend, list_backend_ids
|
||||||
|
from .ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
|
||||||
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
||||||
from .logging import get_logger, setup_logging
|
from .logging import get_logger, setup_logging
|
||||||
from .router import AutoRouter, RunnerEntry
|
from .router import AutoRouter, RunnerEntry
|
||||||
@@ -22,12 +25,46 @@ from .settings import (
|
|||||||
load_settings_if_exists,
|
load_settings_if_exists,
|
||||||
validate_settings_data,
|
validate_settings_data,
|
||||||
)
|
)
|
||||||
from .transports import SetupResult, get_transport, list_transports
|
from .plugins import (
|
||||||
|
COMMAND_GROUP,
|
||||||
|
ENGINE_GROUP,
|
||||||
|
TRANSPORT_GROUP,
|
||||||
|
entrypoint_distribution_name,
|
||||||
|
get_load_errors,
|
||||||
|
is_entrypoint_allowed,
|
||||||
|
list_entrypoints,
|
||||||
|
normalize_allowlist,
|
||||||
|
)
|
||||||
|
from .transports import SetupResult, get_transport
|
||||||
|
from .transport_runtime import TransportRuntime
|
||||||
from .utils.git import resolve_default_base, resolve_main_worktree_root
|
from .utils.git import resolve_default_base, resolve_main_worktree_root
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
|
||||||
|
try:
|
||||||
|
loaded = load_settings_if_exists()
|
||||||
|
except ConfigError:
|
||||||
|
return None, None
|
||||||
|
if loaded is None:
|
||||||
|
return None, None
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_plugins_allowlist(
|
||||||
|
settings: TakopiSettings | None,
|
||||||
|
) -> list[str] | None:
|
||||||
|
if settings is None:
|
||||||
|
return None
|
||||||
|
enabled = [
|
||||||
|
value.strip()
|
||||||
|
for value in settings.plugins.enabled
|
||||||
|
if isinstance(value, str) and value.strip()
|
||||||
|
]
|
||||||
|
return enabled or None
|
||||||
|
|
||||||
|
|
||||||
def _print_version_and_exit() -> None:
|
def _print_version_and_exit() -> None:
|
||||||
typer.echo(__version__)
|
typer.echo(__version__)
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
@@ -72,16 +109,16 @@ def acquire_config_lock(config_path: Path, token: str | None) -> LockHandle:
|
|||||||
raise typer.Exit(code=1) from exc
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
|
||||||
def _default_engine_for_setup(override: str | None) -> str:
|
def _default_engine_for_setup(
|
||||||
|
override: str | None,
|
||||||
|
*,
|
||||||
|
settings: TakopiSettings | None,
|
||||||
|
config_path: Path | None,
|
||||||
|
) -> str:
|
||||||
if override:
|
if override:
|
||||||
return override
|
return override
|
||||||
try:
|
if settings is None or config_path is None:
|
||||||
loaded = load_settings_if_exists()
|
|
||||||
except ConfigError:
|
|
||||||
return "codex"
|
return "codex"
|
||||||
if loaded is None:
|
|
||||||
return "codex"
|
|
||||||
settings, config_path = loaded
|
|
||||||
value = settings.default_engine
|
value = settings.default_engine
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
@@ -95,7 +132,7 @@ def _resolve_default_engine(
|
|||||||
override: str | None,
|
override: str | None,
|
||||||
settings: TakopiSettings,
|
settings: TakopiSettings,
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
backends: list[EngineBackend],
|
engine_ids: list[str],
|
||||||
) -> str:
|
) -> str:
|
||||||
default_engine = override or settings.default_engine or "codex"
|
default_engine = override or settings.default_engine or "codex"
|
||||||
if not isinstance(default_engine, str) or not default_engine.strip():
|
if not isinstance(default_engine, str) or not default_engine.strip():
|
||||||
@@ -103,9 +140,8 @@ def _resolve_default_engine(
|
|||||||
f"Invalid `default_engine` in {config_path}; expected a non-empty string."
|
f"Invalid `default_engine` in {config_path}; expected a non-empty string."
|
||||||
)
|
)
|
||||||
default_engine = default_engine.strip()
|
default_engine = default_engine.strip()
|
||||||
backend_ids = {backend.id for backend in backends}
|
if default_engine not in engine_ids:
|
||||||
if default_engine not in backend_ids:
|
available = ", ".join(sorted(engine_ids))
|
||||||
available = ", ".join(sorted(backend_ids))
|
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"Unknown default engine {default_engine!r}. Available: {available}."
|
f"Unknown default engine {default_engine!r}. Available: {available}."
|
||||||
)
|
)
|
||||||
@@ -176,6 +212,30 @@ def _build_router(
|
|||||||
return AutoRouter(entries=entries, default_engine=default_engine)
|
return AutoRouter(entries=entries, default_engine=default_engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_backends(
|
||||||
|
*,
|
||||||
|
engine_ids: list[str],
|
||||||
|
allowlist: list[str] | None,
|
||||||
|
default_engine: str,
|
||||||
|
) -> list[EngineBackend]:
|
||||||
|
backends: list[EngineBackend] = []
|
||||||
|
load_issues: list[str] = []
|
||||||
|
for engine_id in engine_ids:
|
||||||
|
try:
|
||||||
|
backend = get_backend(engine_id, allowlist=allowlist)
|
||||||
|
except ConfigError as exc:
|
||||||
|
if engine_id == default_engine:
|
||||||
|
raise
|
||||||
|
load_issues.append(f"{engine_id}: {exc}")
|
||||||
|
continue
|
||||||
|
backends.append(backend)
|
||||||
|
if not backends:
|
||||||
|
raise ConfigError("No engine backends are available.")
|
||||||
|
for issue in load_issues:
|
||||||
|
logger.warning("setup.warning", issue=issue)
|
||||||
|
return backends
|
||||||
|
|
||||||
|
|
||||||
def _config_path_display(path: Path) -> str:
|
def _config_path_display(path: Path) -> str:
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
try:
|
try:
|
||||||
@@ -214,10 +274,16 @@ def _run_auto_router(
|
|||||||
setup_logging(debug=debug)
|
setup_logging(debug=debug)
|
||||||
lock_handle: LockHandle | None = None
|
lock_handle: LockHandle | None = None
|
||||||
try:
|
try:
|
||||||
default_engine = _default_engine_for_setup(default_engine_override)
|
settings_hint, config_hint = _load_settings_optional()
|
||||||
engine_backend = get_backend(default_engine)
|
allowlist = _resolve_plugins_allowlist(settings_hint)
|
||||||
|
default_engine = _default_engine_for_setup(
|
||||||
|
default_engine_override,
|
||||||
|
settings=settings_hint,
|
||||||
|
config_path=config_hint,
|
||||||
|
)
|
||||||
|
engine_backend = get_backend(default_engine, allowlist=allowlist)
|
||||||
transport_id = _resolve_transport_id(transport_override)
|
transport_id = _resolve_transport_id(transport_override)
|
||||||
transport_backend = get_transport(transport_id)
|
transport_backend = get_transport(transport_id, allowlist=allowlist)
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
typer.echo(f"error: {e}", err=True)
|
typer.echo(f"error: {e}", err=True)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -227,8 +293,14 @@ def _run_auto_router(
|
|||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
if not transport_backend.interactive_setup(force=True):
|
if not transport_backend.interactive_setup(force=True):
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
default_engine = _default_engine_for_setup(default_engine_override)
|
settings_hint, config_hint = _load_settings_optional()
|
||||||
engine_backend = get_backend(default_engine)
|
allowlist = _resolve_plugins_allowlist(settings_hint)
|
||||||
|
default_engine = _default_engine_for_setup(
|
||||||
|
default_engine_override,
|
||||||
|
settings=settings_hint,
|
||||||
|
config_path=config_hint,
|
||||||
|
)
|
||||||
|
engine_backend = get_backend(default_engine, allowlist=allowlist)
|
||||||
setup = transport_backend.check_setup(
|
setup = transport_backend.check_setup(
|
||||||
engine_backend,
|
engine_backend,
|
||||||
transport_override=transport_override,
|
transport_override=transport_override,
|
||||||
@@ -243,15 +315,27 @@ def _run_auto_router(
|
|||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
if run_onboard and transport_backend.interactive_setup(force=True):
|
if run_onboard and transport_backend.interactive_setup(force=True):
|
||||||
default_engine = _default_engine_for_setup(default_engine_override)
|
settings_hint, config_hint = _load_settings_optional()
|
||||||
engine_backend = get_backend(default_engine)
|
allowlist = _resolve_plugins_allowlist(settings_hint)
|
||||||
|
default_engine = _default_engine_for_setup(
|
||||||
|
default_engine_override,
|
||||||
|
settings=settings_hint,
|
||||||
|
config_path=config_hint,
|
||||||
|
)
|
||||||
|
engine_backend = get_backend(default_engine, allowlist=allowlist)
|
||||||
setup = transport_backend.check_setup(
|
setup = transport_backend.check_setup(
|
||||||
engine_backend,
|
engine_backend,
|
||||||
transport_override=transport_override,
|
transport_override=transport_override,
|
||||||
)
|
)
|
||||||
elif transport_backend.interactive_setup(force=False):
|
elif transport_backend.interactive_setup(force=False):
|
||||||
default_engine = _default_engine_for_setup(default_engine_override)
|
settings_hint, config_hint = _load_settings_optional()
|
||||||
engine_backend = get_backend(default_engine)
|
allowlist = _resolve_plugins_allowlist(settings_hint)
|
||||||
|
default_engine = _default_engine_for_setup(
|
||||||
|
default_engine_override,
|
||||||
|
settings=settings_hint,
|
||||||
|
config_path=config_hint,
|
||||||
|
)
|
||||||
|
engine_backend = get_backend(default_engine, allowlist=allowlist)
|
||||||
setup = transport_backend.check_setup(
|
setup = transport_backend.check_setup(
|
||||||
engine_backend,
|
engine_backend,
|
||||||
transport_override=transport_override,
|
transport_override=transport_override,
|
||||||
@@ -267,17 +351,23 @@ def _run_auto_router(
|
|||||||
settings, config_path = load_settings()
|
settings, config_path = load_settings()
|
||||||
if transport_override and transport_override != settings.transport:
|
if transport_override and transport_override != settings.transport:
|
||||||
settings = settings.model_copy(update={"transport": transport_override})
|
settings = settings.model_copy(update={"transport": transport_override})
|
||||||
backends = list_backends()
|
allowlist = _resolve_plugins_allowlist(settings)
|
||||||
|
engine_ids = list_backend_ids(allowlist=allowlist)
|
||||||
projects = settings.to_projects_config(
|
projects = settings.to_projects_config(
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
engine_ids=[backend.id for backend in backends],
|
engine_ids=engine_ids,
|
||||||
reserved=("cancel",),
|
reserved=("cancel",),
|
||||||
)
|
)
|
||||||
default_engine = _resolve_default_engine(
|
default_engine = _resolve_default_engine(
|
||||||
override=default_engine_override,
|
override=default_engine_override,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
backends=backends,
|
engine_ids=engine_ids,
|
||||||
|
)
|
||||||
|
backends = _load_backends(
|
||||||
|
engine_ids=engine_ids,
|
||||||
|
allowlist=allowlist,
|
||||||
|
default_engine=default_engine,
|
||||||
)
|
)
|
||||||
router = _build_router(
|
router = _build_router(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
@@ -285,18 +375,27 @@ def _run_auto_router(
|
|||||||
backends=backends,
|
backends=backends,
|
||||||
default_engine=default_engine,
|
default_engine=default_engine,
|
||||||
)
|
)
|
||||||
|
transport_config = settings.transport_config(
|
||||||
|
settings.transport, config_path=config_path
|
||||||
|
)
|
||||||
lock_token = transport_backend.lock_token(
|
lock_token = transport_backend.lock_token(
|
||||||
settings=settings,
|
transport_config=transport_config,
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
)
|
)
|
||||||
lock_handle = acquire_config_lock(config_path, lock_token)
|
lock_handle = acquire_config_lock(config_path, lock_token)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=router,
|
||||||
|
projects=projects,
|
||||||
|
allowlist=allowlist,
|
||||||
|
config_path=config_path,
|
||||||
|
plugin_configs=settings.plugins.model_extra,
|
||||||
|
)
|
||||||
transport_backend.build_and_run(
|
transport_backend.build_and_run(
|
||||||
final_notify=final_notify,
|
final_notify=final_notify,
|
||||||
default_engine_override=default_engine_override,
|
default_engine_override=default_engine_override,
|
||||||
settings=settings,
|
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
router=router,
|
transport_config=transport_config,
|
||||||
projects=projects,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
typer.echo(f"error: {e}", err=True)
|
typer.echo(f"error: {e}", err=True)
|
||||||
@@ -364,8 +463,9 @@ def init(
|
|||||||
default_alias = _default_alias_from_path(project_path)
|
default_alias = _default_alias_from_path(project_path)
|
||||||
alias = _prompt_alias(alias, default_alias=default_alias)
|
alias = _prompt_alias(alias, default_alias=default_alias)
|
||||||
|
|
||||||
engine_ids = [backend.id for backend in list_backends()]
|
|
||||||
settings = validate_settings_data(config, config_path=config_path)
|
settings = validate_settings_data(config, config_path=config_path)
|
||||||
|
allowlist = _resolve_plugins_allowlist(settings)
|
||||||
|
engine_ids = list_backend_ids(allowlist=allowlist)
|
||||||
projects_cfg = settings.to_projects_config(
|
projects_cfg = settings.to_projects_config(
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
engine_ids=engine_ids,
|
engine_ids=engine_ids,
|
||||||
@@ -414,25 +514,92 @@ def init(
|
|||||||
typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
|
typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
|
||||||
|
|
||||||
|
|
||||||
def transports_cmd() -> None:
|
def _print_entrypoints(
|
||||||
"""List available transport backends."""
|
label: str, entrypoints: list[EntryPoint], *, allowlist: set[str] | None
|
||||||
ids = list_transports()
|
) -> None:
|
||||||
for transport_id in ids:
|
typer.echo(f"{label}:")
|
||||||
typer.echo(transport_id)
|
if not entrypoints:
|
||||||
|
typer.echo(" (none)")
|
||||||
|
return
|
||||||
|
for ep in entrypoints:
|
||||||
|
dist = entrypoint_distribution_name(ep) or "unknown"
|
||||||
|
status = ""
|
||||||
|
if allowlist is not None:
|
||||||
|
allowed = is_entrypoint_allowed(ep, allowlist)
|
||||||
|
status = " enabled" if allowed else " disabled"
|
||||||
|
typer.echo(f" {ep.name} ({dist}){status}")
|
||||||
|
|
||||||
|
|
||||||
app = typer.Typer(
|
def plugins_cmd(
|
||||||
add_completion=False,
|
load: bool = typer.Option(
|
||||||
invoke_without_command=True,
|
False,
|
||||||
help="Run takopi with auto-router (subcommands override the default engine).",
|
"--load/--no-load",
|
||||||
|
help="Load plugins to validate and surface import errors.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""List discovered plugins and optionally validate them."""
|
||||||
|
settings_hint, _ = _load_settings_optional()
|
||||||
|
allowlist = _resolve_plugins_allowlist(settings_hint)
|
||||||
|
|
||||||
|
allowlist_set = normalize_allowlist(allowlist)
|
||||||
|
engine_eps = list_entrypoints(
|
||||||
|
ENGINE_GROUP,
|
||||||
|
reserved_ids=RESERVED_ENGINE_IDS,
|
||||||
|
)
|
||||||
|
transport_eps = list_entrypoints(TRANSPORT_GROUP)
|
||||||
|
command_eps = list_entrypoints(
|
||||||
|
COMMAND_GROUP,
|
||||||
|
reserved_ids=RESERVED_COMMAND_IDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_print_entrypoints("engine backends", engine_eps, allowlist=allowlist_set)
|
||||||
|
_print_entrypoints("transport backends", transport_eps, allowlist=allowlist_set)
|
||||||
|
_print_entrypoints("command backends", command_eps, allowlist=allowlist_set)
|
||||||
|
|
||||||
app.command(name="init")(init)
|
if load:
|
||||||
app.command(name="transports")(transports_cmd)
|
for ep in engine_eps:
|
||||||
|
if allowlist_set is not None and not is_entrypoint_allowed(
|
||||||
|
ep, allowlist_set
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
get_backend(ep.name, allowlist=allowlist)
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
for ep in transport_eps:
|
||||||
|
if allowlist_set is not None and not is_entrypoint_allowed(
|
||||||
|
ep, allowlist_set
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
get_transport(ep.name, allowlist=allowlist)
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
for ep in command_eps:
|
||||||
|
if allowlist_set is not None and not is_entrypoint_allowed(
|
||||||
|
ep, allowlist_set
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
get_command(ep.name, allowlist=allowlist)
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
errors = get_load_errors()
|
||||||
|
if errors:
|
||||||
|
typer.echo("errors:")
|
||||||
|
for err in errors:
|
||||||
|
group = err.group
|
||||||
|
if group == ENGINE_GROUP:
|
||||||
|
group = "engine"
|
||||||
|
elif group == TRANSPORT_GROUP:
|
||||||
|
group = "transport"
|
||||||
|
elif group == COMMAND_GROUP:
|
||||||
|
group = "command"
|
||||||
|
dist = err.distribution or "unknown"
|
||||||
|
typer.echo(f" {group} {err.name} ({dist}): {err.error}")
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
|
||||||
def app_main(
|
def app_main(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
version: bool = typer.Option(
|
version: bool = typer.Option(
|
||||||
@@ -510,16 +677,43 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]:
|
|||||||
return _cmd
|
return _cmd
|
||||||
|
|
||||||
|
|
||||||
def register_engine_commands() -> None:
|
def _engine_ids_for_cli() -> list[str]:
|
||||||
for backend in list_backends():
|
allowlist: list[str] | None = None
|
||||||
help_text = f"Run with the {backend.id} engine."
|
try:
|
||||||
app.command(name=backend.id, help=help_text)(make_engine_cmd(backend.id))
|
config, _ = load_or_init_config()
|
||||||
|
except ConfigError:
|
||||||
|
return list_backend_ids()
|
||||||
|
raw_plugins = config.get("plugins")
|
||||||
|
if isinstance(raw_plugins, dict):
|
||||||
|
enabled = raw_plugins.get("enabled")
|
||||||
|
if isinstance(enabled, list):
|
||||||
|
allowlist = [
|
||||||
|
value.strip()
|
||||||
|
for value in enabled
|
||||||
|
if isinstance(value, str) and value.strip()
|
||||||
|
]
|
||||||
|
if not allowlist:
|
||||||
|
allowlist = None
|
||||||
|
return list_backend_ids(allowlist=allowlist)
|
||||||
|
|
||||||
|
|
||||||
register_engine_commands()
|
def create_app() -> typer.Typer:
|
||||||
|
app = typer.Typer(
|
||||||
|
add_completion=False,
|
||||||
|
invoke_without_command=True,
|
||||||
|
help="Run takopi with auto-router (subcommands override the default engine).",
|
||||||
|
)
|
||||||
|
app.command(name="init")(init)
|
||||||
|
app.command(name="plugins")(plugins_cmd)
|
||||||
|
app.callback()(app_main)
|
||||||
|
for engine_id in _engine_ids_for_cli():
|
||||||
|
help_text = f"Run with the {engine_id} engine."
|
||||||
|
app.command(name=engine_id, help=help_text)(make_engine_cmd(engine_id))
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
app = create_app()
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, Protocol, overload, runtime_checkable
|
||||||
|
|
||||||
|
from .config import ConfigError
|
||||||
|
from .context import RunContext
|
||||||
|
from .ids import RESERVED_COMMAND_IDS
|
||||||
|
from .model import EngineId
|
||||||
|
from .plugins import (
|
||||||
|
COMMAND_GROUP,
|
||||||
|
PluginLoadFailed,
|
||||||
|
PluginNotFound,
|
||||||
|
load_entrypoint,
|
||||||
|
list_ids,
|
||||||
|
)
|
||||||
|
from .transport import MessageRef, RenderedMessage
|
||||||
|
from .transport_runtime import TransportRuntime
|
||||||
|
|
||||||
|
RunMode = Literal["emit", "capture"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RunRequest:
|
||||||
|
prompt: str
|
||||||
|
engine: EngineId | None = None
|
||||||
|
context: RunContext | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RunResult:
|
||||||
|
engine: EngineId
|
||||||
|
message: RenderedMessage | None
|
||||||
|
|
||||||
|
|
||||||
|
class CommandExecutor(Protocol):
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
message: RenderedMessage | str,
|
||||||
|
*,
|
||||||
|
reply_to: MessageRef | None = None,
|
||||||
|
notify: bool = True,
|
||||||
|
) -> MessageRef | None: ...
|
||||||
|
|
||||||
|
async def run_one(
|
||||||
|
self, request: RunRequest, *, mode: RunMode = "emit"
|
||||||
|
) -> RunResult: ...
|
||||||
|
|
||||||
|
async def run_many(
|
||||||
|
self,
|
||||||
|
requests: Sequence[RunRequest],
|
||||||
|
*,
|
||||||
|
mode: RunMode = "emit",
|
||||||
|
parallel: bool = False,
|
||||||
|
) -> list[RunResult]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CommandContext:
|
||||||
|
command: str
|
||||||
|
text: str
|
||||||
|
args_text: str
|
||||||
|
args: tuple[str, ...]
|
||||||
|
message: MessageRef
|
||||||
|
reply_to: MessageRef | None
|
||||||
|
reply_text: str | None
|
||||||
|
config_path: Path | None
|
||||||
|
plugin_config: dict[str, Any]
|
||||||
|
runtime: TransportRuntime
|
||||||
|
executor: CommandExecutor
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CommandResult:
|
||||||
|
text: str
|
||||||
|
notify: bool = True
|
||||||
|
reply_to: MessageRef | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CommandBackend(Protocol):
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_command_backend(backend: object, ep) -> None:
|
||||||
|
if not isinstance(backend, CommandBackend):
|
||||||
|
raise TypeError(f"{ep.value} is not a CommandBackend")
|
||||||
|
if backend.id != ep.name:
|
||||||
|
raise ValueError(
|
||||||
|
f"{ep.value} command id {backend.id!r} does not match entrypoint {ep.name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_command(
|
||||||
|
command_id: str,
|
||||||
|
*,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
required: Literal[True] = True,
|
||||||
|
) -> CommandBackend: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_command(
|
||||||
|
command_id: str,
|
||||||
|
*,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
required: Literal[False],
|
||||||
|
) -> CommandBackend | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def get_command(
|
||||||
|
command_id: str,
|
||||||
|
*,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
required: bool = True,
|
||||||
|
) -> CommandBackend | None:
|
||||||
|
if command_id.lower() in RESERVED_COMMAND_IDS:
|
||||||
|
raise ConfigError(f"Command id {command_id!r} is reserved.")
|
||||||
|
try:
|
||||||
|
backend = load_entrypoint(
|
||||||
|
COMMAND_GROUP,
|
||||||
|
command_id,
|
||||||
|
allowlist=allowlist,
|
||||||
|
validator=_validate_command_backend,
|
||||||
|
)
|
||||||
|
except PluginNotFound as exc:
|
||||||
|
if not required:
|
||||||
|
return None
|
||||||
|
if exc.available:
|
||||||
|
available = ", ".join(exc.available)
|
||||||
|
message = f"Unknown command {command_id!r}. Available: {available}."
|
||||||
|
else:
|
||||||
|
message = f"Unknown command {command_id!r}."
|
||||||
|
raise ConfigError(message) from exc
|
||||||
|
except PluginLoadFailed as exc:
|
||||||
|
raise ConfigError(f"Failed to load command {command_id!r}: {exc}") from exc
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def list_command_ids(*, allowlist: Iterable[str] | None = None) -> list[str]:
|
||||||
|
return list_ids(
|
||||||
|
COMMAND_GROUP,
|
||||||
|
allowlist=allowlist,
|
||||||
|
reserved_ids=RESERVED_COMMAND_IDS,
|
||||||
|
)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .config import ProjectsConfig
|
||||||
|
from .context import RunContext
|
||||||
|
from .model import EngineId
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedDirectives:
|
||||||
|
prompt: str
|
||||||
|
engine: EngineId | None
|
||||||
|
project: str | None
|
||||||
|
branch: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class DirectiveError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_directives(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
engine_ids: tuple[EngineId, ...],
|
||||||
|
projects: ProjectsConfig,
|
||||||
|
) -> ParsedDirectives:
|
||||||
|
if not text:
|
||||||
|
return ParsedDirectives(prompt="", engine=None, project=None, branch=None)
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
idx = next((i for i, line in enumerate(lines) if line.strip()), None)
|
||||||
|
if idx is None:
|
||||||
|
return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
|
||||||
|
|
||||||
|
line = lines[idx].lstrip()
|
||||||
|
tokens = line.split()
|
||||||
|
if not tokens:
|
||||||
|
return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
|
||||||
|
|
||||||
|
engine_map = {engine.lower(): engine for engine in engine_ids}
|
||||||
|
project_map = {alias.lower(): alias for alias in projects.projects}
|
||||||
|
|
||||||
|
engine: EngineId | None = None
|
||||||
|
project: str | None = None
|
||||||
|
branch: str | None = None
|
||||||
|
consumed = 0
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
if token.startswith("/"):
|
||||||
|
name = token[1:]
|
||||||
|
if "@" in name:
|
||||||
|
name = name.split("@", 1)[0]
|
||||||
|
if not name:
|
||||||
|
break
|
||||||
|
key = name.lower()
|
||||||
|
engine_candidate = engine_map.get(key)
|
||||||
|
project_candidate = project_map.get(key)
|
||||||
|
if engine_candidate is not None:
|
||||||
|
if engine is not None:
|
||||||
|
raise DirectiveError("multiple engine directives")
|
||||||
|
engine = engine_candidate
|
||||||
|
consumed += 1
|
||||||
|
continue
|
||||||
|
if project_candidate is not None:
|
||||||
|
if project is not None:
|
||||||
|
raise DirectiveError("multiple project directives")
|
||||||
|
project = project_candidate
|
||||||
|
consumed += 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
if token.startswith("@"):
|
||||||
|
value = token[1:]
|
||||||
|
if not value:
|
||||||
|
break
|
||||||
|
if branch is not None:
|
||||||
|
raise DirectiveError("multiple @branch directives")
|
||||||
|
branch = value
|
||||||
|
consumed += 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if consumed == 0:
|
||||||
|
return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
|
||||||
|
|
||||||
|
if consumed < len(tokens):
|
||||||
|
remainder = " ".join(tokens[consumed:])
|
||||||
|
lines[idx] = remainder
|
||||||
|
else:
|
||||||
|
lines.pop(idx)
|
||||||
|
|
||||||
|
prompt = "\n".join(lines).strip()
|
||||||
|
return ParsedDirectives(
|
||||||
|
prompt=prompt, engine=engine, project=project, branch=branch
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_context_line(
|
||||||
|
text: str | None, *, projects: ProjectsConfig
|
||||||
|
) -> RunContext | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
ctx: RunContext | None = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("`") and stripped.endswith("`") and len(stripped) > 1:
|
||||||
|
stripped = stripped[1:-1].strip()
|
||||||
|
elif stripped.startswith("`"):
|
||||||
|
stripped = stripped[1:].strip()
|
||||||
|
elif stripped.endswith("`"):
|
||||||
|
stripped = stripped[:-1].strip()
|
||||||
|
if not stripped.lower().startswith("ctx:"):
|
||||||
|
continue
|
||||||
|
content = stripped.split(":", 1)[1].strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
tokens = content.split()
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
project = tokens[0]
|
||||||
|
branch = None
|
||||||
|
if len(tokens) >= 2:
|
||||||
|
if tokens[1] == "@" and len(tokens) >= 3:
|
||||||
|
branch = tokens[2]
|
||||||
|
elif tokens[1].startswith("@"):
|
||||||
|
branch = tokens[1][1:]
|
||||||
|
project_key = project.lower()
|
||||||
|
if project_key not in projects.projects:
|
||||||
|
raise DirectiveError(f"unknown project {project!r} in ctx line")
|
||||||
|
ctx = RunContext(project=project_key, branch=branch)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def format_context_line(
|
||||||
|
context: RunContext | None, *, projects: ProjectsConfig
|
||||||
|
) -> str | None:
|
||||||
|
if context is None or context.project is None:
|
||||||
|
return None
|
||||||
|
project_cfg = projects.projects.get(context.project)
|
||||||
|
alias = project_cfg.alias if project_cfg is not None else context.project
|
||||||
|
if context.branch:
|
||||||
|
return f"`ctx: {alias} @ {context.branch}`"
|
||||||
|
return f"`ctx: {alias}`"
|
||||||
+55
-59
@@ -1,71 +1,67 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
from typing import Iterable
|
||||||
import pkgutil
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from functools import cache
|
|
||||||
from pathlib import Path
|
|
||||||
from types import MappingProxyType
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .backends import EngineBackend, EngineConfig
|
from .backends import EngineBackend
|
||||||
from .config import ConfigError
|
from .config import ConfigError
|
||||||
|
from .plugins import (
|
||||||
|
ENGINE_GROUP,
|
||||||
|
PluginLoadFailed,
|
||||||
|
PluginNotFound,
|
||||||
|
load_entrypoint,
|
||||||
|
list_ids,
|
||||||
|
)
|
||||||
|
from .ids import RESERVED_ENGINE_IDS
|
||||||
|
|
||||||
|
|
||||||
def _discover_backends() -> dict[str, EngineBackend]:
|
def _validate_engine_backend(backend: object, ep) -> None:
|
||||||
import takopi.runners as runners_pkg
|
|
||||||
|
|
||||||
backends: dict[str, EngineBackend] = {}
|
|
||||||
prefix = runners_pkg.__name__ + "."
|
|
||||||
|
|
||||||
for module_info in pkgutil.iter_modules(runners_pkg.__path__, prefix):
|
|
||||||
module_name = module_info.name
|
|
||||||
mod = importlib.import_module(module_name)
|
|
||||||
|
|
||||||
backend = getattr(mod, "BACKEND", None)
|
|
||||||
if backend is None:
|
|
||||||
continue
|
|
||||||
if not isinstance(backend, EngineBackend):
|
if not isinstance(backend, EngineBackend):
|
||||||
raise RuntimeError(f"{module_name}.BACKEND is not an EngineBackend")
|
raise TypeError(f"{ep.value} is not an EngineBackend")
|
||||||
if backend.id in backends:
|
if backend.id != ep.name:
|
||||||
raise RuntimeError(f"Duplicate backend id: {backend.id}")
|
raise ValueError(
|
||||||
backends[backend.id] = backend
|
f"{ep.value} engine id {backend.id!r} does not match entrypoint {ep.name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend(
|
||||||
|
engine_id: str, *, allowlist: Iterable[str] | None = None
|
||||||
|
) -> EngineBackend:
|
||||||
|
if engine_id.lower() in RESERVED_ENGINE_IDS:
|
||||||
|
raise ConfigError(f"Engine id {engine_id!r} is reserved.")
|
||||||
|
try:
|
||||||
|
backend = load_entrypoint(
|
||||||
|
ENGINE_GROUP,
|
||||||
|
engine_id,
|
||||||
|
allowlist=allowlist,
|
||||||
|
validator=_validate_engine_backend,
|
||||||
|
)
|
||||||
|
except PluginNotFound as exc:
|
||||||
|
if exc.available:
|
||||||
|
available = ", ".join(exc.available)
|
||||||
|
message = f"Unknown engine {engine_id!r}. Available: {available}."
|
||||||
|
else:
|
||||||
|
message = f"Unknown engine {engine_id!r}."
|
||||||
|
raise ConfigError(message) from exc
|
||||||
|
except PluginLoadFailed as exc:
|
||||||
|
raise ConfigError(f"Failed to load engine {engine_id!r}: {exc}") from exc
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def list_backends(*, allowlist: Iterable[str] | None = None) -> list[EngineBackend]:
|
||||||
|
backends: list[EngineBackend] = []
|
||||||
|
for engine_id in list_backend_ids(allowlist=allowlist):
|
||||||
|
try:
|
||||||
|
backends.append(get_backend(engine_id, allowlist=allowlist))
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
if not backends:
|
||||||
|
raise ConfigError("No engine backends are available.")
|
||||||
return backends
|
return backends
|
||||||
|
|
||||||
|
|
||||||
@cache
|
def list_backend_ids(*, allowlist: Iterable[str] | None = None) -> list[str]:
|
||||||
def _backends() -> Mapping[str, EngineBackend]:
|
return list_ids(
|
||||||
backends = _discover_backends()
|
ENGINE_GROUP,
|
||||||
return MappingProxyType(backends)
|
allowlist=allowlist,
|
||||||
|
reserved_ids=RESERVED_ENGINE_IDS,
|
||||||
|
|
||||||
def get_backend(engine_id: str) -> EngineBackend:
|
|
||||||
backends = _backends()
|
|
||||||
try:
|
|
||||||
return backends[engine_id]
|
|
||||||
except KeyError as exc:
|
|
||||||
available = ", ".join(sorted(backends))
|
|
||||||
raise ConfigError(
|
|
||||||
f"Unknown engine {engine_id!r}. Available: {available}."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def list_backends() -> list[EngineBackend]:
|
|
||||||
backends = _backends()
|
|
||||||
return [backends[key] for key in sorted(backends)]
|
|
||||||
|
|
||||||
|
|
||||||
def list_backend_ids() -> list[str]:
|
|
||||||
return sorted(_backends())
|
|
||||||
|
|
||||||
|
|
||||||
def get_engine_config(
|
|
||||||
config: dict[str, Any], engine_id: str, config_path: Path
|
|
||||||
) -> EngineConfig:
|
|
||||||
engine_cfg = config.get(engine_id) or {}
|
|
||||||
if not isinstance(engine_cfg, dict):
|
|
||||||
raise ConfigError(
|
|
||||||
f"Invalid `{engine_id}` config in {config_path}; expected a table."
|
|
||||||
)
|
)
|
||||||
return engine_cfg
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
ID_PATTERN = r"^[a-z0-9_]{1,32}$"
|
||||||
|
_ID_RE = re.compile(ID_PATTERN)
|
||||||
|
|
||||||
|
RESERVED_CLI_COMMANDS = frozenset({"init", "plugins"})
|
||||||
|
RESERVED_CHAT_COMMANDS = frozenset({"cancel"})
|
||||||
|
RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
|
||||||
|
RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_id(value: str) -> bool:
|
||||||
|
return bool(_ID_RE.fullmatch(value))
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib.metadata import EntryPoint, entry_points
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .ids import ID_PATTERN, is_valid_id
|
||||||
|
|
||||||
|
ENGINE_GROUP = "takopi.engine_backends"
|
||||||
|
TRANSPORT_GROUP = "takopi.transport_backends"
|
||||||
|
COMMAND_GROUP = "takopi.command_backends"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PluginLoadError:
|
||||||
|
group: str
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
distribution: str | None
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
|
class PluginLoadFailed(RuntimeError):
|
||||||
|
def __init__(self, error: PluginLoadError) -> None:
|
||||||
|
super().__init__(error.error)
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
|
class PluginNotFound(LookupError):
|
||||||
|
def __init__(self, group: str, name: str, available: Iterable[str]) -> None:
|
||||||
|
self.group = group
|
||||||
|
self.name = name
|
||||||
|
self.available = tuple(sorted(available))
|
||||||
|
message = f"{group} plugin {name!r} not found"
|
||||||
|
if self.available:
|
||||||
|
message = f"{message}. Available: {', '.join(self.available)}."
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
_LOAD_ERRORS: list[PluginLoadError] = []
|
||||||
|
_LOAD_ERROR_KEYS: set[tuple[str, str, str, str | None, str]] = set()
|
||||||
|
_LOADED: dict[tuple[str, str], Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _error_key(error: PluginLoadError) -> tuple[str, str, str, str | None, str]:
|
||||||
|
return (error.group, error.name, error.value, error.distribution, error.error)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_error(error: PluginLoadError) -> None:
|
||||||
|
key = _error_key(error)
|
||||||
|
if key in _LOAD_ERROR_KEYS:
|
||||||
|
return
|
||||||
|
_LOAD_ERROR_KEYS.add(key)
|
||||||
|
_LOAD_ERRORS.append(error)
|
||||||
|
|
||||||
|
|
||||||
|
def get_load_errors() -> tuple[PluginLoadError, ...]:
|
||||||
|
return tuple(_LOAD_ERRORS)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_load_errors(*, group: str | None = None, name: str | None = None) -> None:
|
||||||
|
if group is None and name is None:
|
||||||
|
_LOAD_ERRORS.clear()
|
||||||
|
_LOAD_ERROR_KEYS.clear()
|
||||||
|
return
|
||||||
|
remaining: list[PluginLoadError] = []
|
||||||
|
_LOAD_ERROR_KEYS.clear()
|
||||||
|
for error in _LOAD_ERRORS:
|
||||||
|
if group is not None and error.group != group:
|
||||||
|
remaining.append(error)
|
||||||
|
_LOAD_ERROR_KEYS.add(_error_key(error))
|
||||||
|
continue
|
||||||
|
if name is not None and error.name != name:
|
||||||
|
remaining.append(error)
|
||||||
|
_LOAD_ERROR_KEYS.add(_error_key(error))
|
||||||
|
continue
|
||||||
|
_LOAD_ERRORS[:] = remaining
|
||||||
|
|
||||||
|
|
||||||
|
def reset_plugin_state() -> None:
|
||||||
|
clear_load_errors()
|
||||||
|
_LOADED.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _select_entrypoints(group: str) -> list[EntryPoint]:
|
||||||
|
eps = entry_points()
|
||||||
|
if hasattr(eps, "select"):
|
||||||
|
return list(eps.select(group=group))
|
||||||
|
if isinstance(eps, Mapping):
|
||||||
|
return list(eps.get(group, []))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def entrypoint_distribution_name(ep: EntryPoint) -> str | None:
|
||||||
|
dist = getattr(ep, "dist", None)
|
||||||
|
if dist is None:
|
||||||
|
return None
|
||||||
|
name = getattr(dist, "name", None)
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
metadata = getattr(dist, "metadata", None)
|
||||||
|
if metadata is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return metadata["Name"]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_allowlist(allowlist: Iterable[str] | None) -> set[str] | None:
|
||||||
|
if allowlist is None:
|
||||||
|
return None
|
||||||
|
cleaned = {item.strip().lower() for item in allowlist if item and item.strip()}
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
def is_entrypoint_allowed(ep: EntryPoint, allowlist: set[str] | None) -> bool:
|
||||||
|
if allowlist is None:
|
||||||
|
return True
|
||||||
|
dist_name = entrypoint_distribution_name(ep)
|
||||||
|
if dist_name is None:
|
||||||
|
return False
|
||||||
|
return dist_name.lower() in allowlist
|
||||||
|
|
||||||
|
|
||||||
|
def _entrypoint_sort_key(ep: EntryPoint) -> tuple[str, str, str]:
|
||||||
|
dist = entrypoint_distribution_name(ep) or ""
|
||||||
|
return (ep.name, dist, ep.value)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_reserved(reserved: Iterable[str] | None) -> set[str] | None:
|
||||||
|
if reserved is None:
|
||||||
|
return None
|
||||||
|
cleaned = {item.strip().lower() for item in reserved if item and item.strip()}
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_entrypoints(
|
||||||
|
group: str,
|
||||||
|
*,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
reserved_ids: Iterable[str] | None = None,
|
||||||
|
) -> tuple[dict[str, EntryPoint], dict[str, list[EntryPoint]]]:
|
||||||
|
allow = normalize_allowlist(allowlist)
|
||||||
|
reserved = _normalize_reserved(reserved_ids)
|
||||||
|
raw_eps = _select_entrypoints(group)
|
||||||
|
eps = [ep for ep in raw_eps if is_entrypoint_allowed(ep, allow)]
|
||||||
|
eps.sort(key=_entrypoint_sort_key)
|
||||||
|
|
||||||
|
by_name: dict[str, EntryPoint] = {}
|
||||||
|
duplicates: dict[str, list[EntryPoint]] = {}
|
||||||
|
|
||||||
|
for ep in eps:
|
||||||
|
if not is_valid_id(ep.name):
|
||||||
|
_record_error(
|
||||||
|
PluginLoadError(
|
||||||
|
group=group,
|
||||||
|
name=ep.name,
|
||||||
|
value=ep.value,
|
||||||
|
distribution=entrypoint_distribution_name(ep),
|
||||||
|
error=(f"invalid plugin id {ep.name!r}; must match {ID_PATTERN}"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if reserved is not None and ep.name.lower() in reserved:
|
||||||
|
_record_error(
|
||||||
|
PluginLoadError(
|
||||||
|
group=group,
|
||||||
|
name=ep.name,
|
||||||
|
value=ep.value,
|
||||||
|
distribution=entrypoint_distribution_name(ep),
|
||||||
|
error=f"reserved plugin id {ep.name!r} is not allowed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
existing = by_name.get(ep.name)
|
||||||
|
if existing is None:
|
||||||
|
by_name[ep.name] = ep
|
||||||
|
continue
|
||||||
|
duplicates.setdefault(ep.name, [existing]).append(ep)
|
||||||
|
|
||||||
|
for name, items in duplicates.items():
|
||||||
|
providers = ", ".join(
|
||||||
|
sorted(
|
||||||
|
{entrypoint_distribution_name(item) or "<unknown>" for item in items}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message = f"duplicate plugin id {name!r} from {providers}"
|
||||||
|
for item in items:
|
||||||
|
_record_error(
|
||||||
|
PluginLoadError(
|
||||||
|
group=group,
|
||||||
|
name=name,
|
||||||
|
value=item.value,
|
||||||
|
distribution=entrypoint_distribution_name(item),
|
||||||
|
error=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
by_name.pop(name, None)
|
||||||
|
|
||||||
|
return by_name, duplicates
|
||||||
|
|
||||||
|
|
||||||
|
def list_entrypoints(
|
||||||
|
group: str,
|
||||||
|
*,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
reserved_ids: Iterable[str] | None = None,
|
||||||
|
) -> list[EntryPoint]:
|
||||||
|
by_name, _ = _discover_entrypoints(
|
||||||
|
group, allowlist=allowlist, reserved_ids=reserved_ids
|
||||||
|
)
|
||||||
|
return [by_name[name] for name in sorted(by_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def list_ids(
|
||||||
|
group: str,
|
||||||
|
*,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
reserved_ids: Iterable[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
return sorted(
|
||||||
|
ep.name
|
||||||
|
for ep in list_entrypoints(
|
||||||
|
group, allowlist=allowlist, reserved_ids=reserved_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_entrypoint(
|
||||||
|
group: str,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
validator: Callable[[Any, EntryPoint], None] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
by_name, duplicates = _discover_entrypoints(group, allowlist=allowlist)
|
||||||
|
if name in duplicates:
|
||||||
|
items = duplicates[name]
|
||||||
|
providers = ", ".join(
|
||||||
|
sorted(
|
||||||
|
{entrypoint_distribution_name(item) or "<unknown>" for item in items}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
error = PluginLoadError(
|
||||||
|
group=group,
|
||||||
|
name=name,
|
||||||
|
value=items[0].value,
|
||||||
|
distribution=entrypoint_distribution_name(items[0]),
|
||||||
|
error=f"duplicate plugin id {name!r} from {providers}",
|
||||||
|
)
|
||||||
|
_record_error(error)
|
||||||
|
raise PluginLoadFailed(error)
|
||||||
|
|
||||||
|
ep = by_name.get(name)
|
||||||
|
if ep is None:
|
||||||
|
raise PluginNotFound(group, name, by_name)
|
||||||
|
|
||||||
|
key = (group, name)
|
||||||
|
if key in _LOADED:
|
||||||
|
return _LOADED[key]
|
||||||
|
|
||||||
|
try:
|
||||||
|
loaded = ep.load()
|
||||||
|
if validator is not None:
|
||||||
|
validator(loaded, ep)
|
||||||
|
except PluginLoadFailed:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
error = PluginLoadError(
|
||||||
|
group=group,
|
||||||
|
name=ep.name,
|
||||||
|
value=ep.value,
|
||||||
|
distribution=entrypoint_distribution_name(ep),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
_record_error(error)
|
||||||
|
raise PluginLoadFailed(error) from exc
|
||||||
|
|
||||||
|
_LOADED[key] = loaded
|
||||||
|
clear_load_errors(group=group, name=name)
|
||||||
|
return loaded
|
||||||
@@ -323,6 +323,20 @@ def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str,
|
|||||||
return tg.bot_token.get_secret_value().strip(), tg.chat_id
|
return tg.bot_token.get_secret_value().strip(), tg.chat_id
|
||||||
|
|
||||||
|
|
||||||
|
def require_telegram_config(
|
||||||
|
config: dict[str, object], config_path: Path
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
raw_token = config.get("bot_token")
|
||||||
|
if raw_token is None or not isinstance(raw_token, str) or not raw_token.strip():
|
||||||
|
raise ConfigError(f"Missing bot token in {config_path}.")
|
||||||
|
raw_chat_id = config.get("chat_id")
|
||||||
|
if raw_chat_id is None:
|
||||||
|
raise ConfigError(f"Missing chat_id in {config_path}.")
|
||||||
|
if isinstance(raw_chat_id, bool) or not isinstance(raw_chat_id, int):
|
||||||
|
raise ConfigError(f"Invalid `chat_id` in {config_path}; expected an integer.")
|
||||||
|
return raw_token.strip(), raw_chat_id
|
||||||
|
|
||||||
|
|
||||||
def _resolve_config_path(path: str | Path | None) -> Path:
|
def _resolve_config_path(path: str | Path | None) -> Path:
|
||||||
return Path(path).expanduser() if path else HOME_CONFIG_PATH
|
return Path(path).expanduser() if path else HOME_CONFIG_PATH
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Telegram-specific clients and adapters."""
|
"""Telegram-specific clients and adapters."""
|
||||||
|
|
||||||
from .client import parse_incoming_update, poll_incoming
|
from .client import parse_incoming_update, poll_incoming
|
||||||
|
from .types import TelegramIncomingMessage
|
||||||
|
|
||||||
__all__ = ["parse_incoming_update", "poll_incoming"]
|
__all__ = [
|
||||||
|
"TelegramIncomingMessage",
|
||||||
|
"parse_incoming_update",
|
||||||
|
"poll_incoming",
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ from pathlib import Path
|
|||||||
import anyio
|
import anyio
|
||||||
|
|
||||||
from ..backends import EngineBackend
|
from ..backends import EngineBackend
|
||||||
from ..config import ProjectsConfig
|
|
||||||
from ..router import AutoRouter
|
|
||||||
from ..runner_bridge import ExecBridgeConfig
|
from ..runner_bridge import ExecBridgeConfig
|
||||||
from ..settings import TakopiSettings, require_telegram
|
from ..settings import require_telegram_config
|
||||||
from ..transports import SetupResult, TransportBackend
|
from ..transports import SetupResult, TransportBackend
|
||||||
|
from ..transport_runtime import TransportRuntime
|
||||||
from .bridge import (
|
from .bridge import (
|
||||||
TelegramBridgeConfig,
|
TelegramBridgeConfig,
|
||||||
TelegramPresenter,
|
TelegramPresenter,
|
||||||
@@ -22,24 +21,22 @@ from .onboarding import check_setup, interactive_setup
|
|||||||
|
|
||||||
|
|
||||||
def _build_startup_message(
|
def _build_startup_message(
|
||||||
router: AutoRouter,
|
runtime: TransportRuntime,
|
||||||
projects: ProjectsConfig,
|
|
||||||
*,
|
*,
|
||||||
startup_pwd: str,
|
startup_pwd: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
available_engines = [entry.engine for entry in router.available_entries]
|
available_engines = list(runtime.available_engine_ids())
|
||||||
missing_engines = [entry.engine for entry in router.entries if not entry.available]
|
missing_engines = list(runtime.missing_engine_ids())
|
||||||
engine_list = ", ".join(available_engines) if available_engines else "none"
|
engine_list = ", ".join(available_engines) if available_engines else "none"
|
||||||
if missing_engines:
|
if missing_engines:
|
||||||
engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})"
|
engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})"
|
||||||
project_aliases = sorted(
|
project_aliases = sorted(
|
||||||
{project.alias for project in projects.projects.values()},
|
{alias for alias in runtime.project_aliases()}, key=str.lower
|
||||||
key=str.lower,
|
|
||||||
)
|
)
|
||||||
project_list = ", ".join(project_aliases) if project_aliases else "none"
|
project_list = ", ".join(project_aliases) if project_aliases else "none"
|
||||||
return (
|
return (
|
||||||
f"\N{OCTOPUS} **takopi is ready**\n\n"
|
f"\N{OCTOPUS} **takopi is ready**\n\n"
|
||||||
f"default: `{router.default_engine}` \n"
|
f"default: `{runtime.default_engine}` \n"
|
||||||
f"agents: `{engine_list}` \n"
|
f"agents: `{engine_list}` \n"
|
||||||
f"projects: `{project_list}` \n"
|
f"projects: `{project_list}` \n"
|
||||||
f"working in: `{startup_pwd}`"
|
f"working in: `{startup_pwd}`"
|
||||||
@@ -61,24 +58,25 @@ class TelegramBackend(TransportBackend):
|
|||||||
def interactive_setup(self, *, force: bool) -> bool:
|
def interactive_setup(self, *, force: bool) -> bool:
|
||||||
return interactive_setup(force=force)
|
return interactive_setup(force=force)
|
||||||
|
|
||||||
def lock_token(self, *, settings: TakopiSettings, config_path: Path) -> str | None:
|
def lock_token(
|
||||||
token, _ = require_telegram(settings, config_path)
|
self, *, transport_config: dict[str, object], config_path: Path
|
||||||
|
) -> str | None:
|
||||||
|
token, _ = require_telegram_config(transport_config, config_path)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def build_and_run(
|
def build_and_run(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
settings: TakopiSettings,
|
transport_config: dict[str, object],
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
router: AutoRouter,
|
runtime: TransportRuntime,
|
||||||
projects: ProjectsConfig,
|
|
||||||
final_notify: bool,
|
final_notify: bool,
|
||||||
default_engine_override: str | None,
|
default_engine_override: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
token, chat_id = require_telegram(settings, config_path)
|
_ = default_engine_override
|
||||||
|
token, chat_id = require_telegram_config(transport_config, config_path)
|
||||||
startup_msg = _build_startup_message(
|
startup_msg = _build_startup_message(
|
||||||
router,
|
runtime,
|
||||||
projects,
|
|
||||||
startup_pwd=os.getcwd(),
|
startup_pwd=os.getcwd(),
|
||||||
)
|
)
|
||||||
bot = TelegramClient(token)
|
bot = TelegramClient(token)
|
||||||
@@ -91,13 +89,13 @@ class TelegramBackend(TransportBackend):
|
|||||||
)
|
)
|
||||||
cfg = TelegramBridgeConfig(
|
cfg = TelegramBridgeConfig(
|
||||||
bot=bot,
|
bot=bot,
|
||||||
router=router,
|
runtime=runtime,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
startup_msg=startup_msg,
|
startup_msg=startup_msg,
|
||||||
exec_cfg=exec_cfg,
|
exec_cfg=exec_cfg,
|
||||||
projects=projects,
|
|
||||||
)
|
)
|
||||||
anyio.run(run_main_loop, cfg)
|
anyio.run(run_main_loop, cfg)
|
||||||
|
|
||||||
|
|
||||||
telegram_backend = TelegramBackend()
|
telegram_backend = TelegramBackend()
|
||||||
|
BACKEND = telegram_backend
|
||||||
|
|||||||
+371
-295
@@ -1,13 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
import shlex
|
||||||
from dataclasses import dataclass, field
|
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||||
import re
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
|
||||||
from ..config import ProjectsConfig, empty_projects_config
|
from ..commands import (
|
||||||
|
CommandContext,
|
||||||
|
CommandExecutor,
|
||||||
|
RunMode,
|
||||||
|
RunRequest,
|
||||||
|
RunResult,
|
||||||
|
get_command,
|
||||||
|
list_command_ids,
|
||||||
|
)
|
||||||
from ..context import RunContext
|
from ..context import RunContext
|
||||||
|
from ..config import ConfigError
|
||||||
|
from ..directives import DirectiveError
|
||||||
|
from ..ids import RESERVED_COMMAND_IDS, is_valid_id
|
||||||
from ..runner_bridge import (
|
from ..runner_bridge import (
|
||||||
ExecBridgeConfig,
|
ExecBridgeConfig,
|
||||||
IncomingMessage as RunnerIncomingMessage,
|
IncomingMessage as RunnerIncomingMessage,
|
||||||
@@ -19,31 +30,22 @@ from ..logging import bind_run_context, clear_context, get_logger
|
|||||||
from ..markdown import MarkdownFormatter, MarkdownParts
|
from ..markdown import MarkdownFormatter, MarkdownParts
|
||||||
from ..model import EngineId, ResumeToken
|
from ..model import EngineId, ResumeToken
|
||||||
from ..progress import ProgressState, ProgressTracker
|
from ..progress import ProgressState, ProgressTracker
|
||||||
from ..router import AutoRouter, RunnerUnavailableError
|
from ..router import RunnerUnavailableError
|
||||||
from ..runner import Runner
|
from ..runner import Runner
|
||||||
from ..scheduler import ThreadJob, ThreadScheduler
|
from ..scheduler import ThreadJob, ThreadScheduler
|
||||||
from ..transport import (
|
from ..transport import MessageRef, RenderedMessage, SendOptions, Transport
|
||||||
IncomingMessage as TransportIncomingMessage,
|
from ..plugins import COMMAND_GROUP, list_entrypoints
|
||||||
MessageRef,
|
|
||||||
RenderedMessage,
|
|
||||||
SendOptions,
|
|
||||||
Transport,
|
|
||||||
)
|
|
||||||
from ..utils.paths import reset_run_base_dir, set_run_base_dir
|
from ..utils.paths import reset_run_base_dir, set_run_base_dir
|
||||||
from ..worktrees import WorktreeError, resolve_run_cwd
|
from ..transport_runtime import TransportRuntime
|
||||||
from .client import BotClient, poll_incoming
|
from .client import BotClient, poll_incoming
|
||||||
|
from .types import TelegramIncomingMessage
|
||||||
from .render import prepare_telegram
|
from .render import prepare_telegram
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
_COMMAND_RE = re.compile(r"^[a-z0-9_]{1,32}$")
|
|
||||||
_MAX_BOT_COMMANDS = 100
|
_MAX_BOT_COMMANDS = 100
|
||||||
|
|
||||||
|
|
||||||
def _is_valid_bot_command(command: str) -> bool:
|
|
||||||
return bool(_COMMAND_RE.fullmatch(command))
|
|
||||||
|
|
||||||
|
|
||||||
def _is_cancel_command(text: str) -> bool:
|
def _is_cancel_command(text: str) -> bool:
|
||||||
stripped = text.strip()
|
stripped = text.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
@@ -52,264 +54,75 @@ def _is_cancel_command(text: str) -> bool:
|
|||||||
return command == "/cancel" or command.startswith("/cancel@")
|
return command == "/cancel" or command.startswith("/cancel@")
|
||||||
|
|
||||||
|
|
||||||
def _strip_engine_command(
|
def _parse_slash_command(text: str) -> tuple[str | None, str]:
|
||||||
text: str, *, engine_ids: tuple[EngineId, ...]
|
stripped = text.lstrip()
|
||||||
) -> tuple[str, EngineId | None]:
|
if not stripped.startswith("/"):
|
||||||
if not text:
|
return None, text
|
||||||
return text, None
|
lines = stripped.splitlines()
|
||||||
|
if not lines:
|
||||||
if not engine_ids:
|
return None, text
|
||||||
return text, None
|
first_line = lines[0]
|
||||||
|
token, _, rest = first_line.partition(" ")
|
||||||
engine_map = {engine.lower(): engine for engine in engine_ids}
|
command = token[1:]
|
||||||
lines = text.splitlines()
|
if not command:
|
||||||
idx = next((i for i, line in enumerate(lines) if line.strip()), None)
|
return None, text
|
||||||
if idx is None:
|
|
||||||
return text, None
|
|
||||||
|
|
||||||
line = lines[idx].lstrip()
|
|
||||||
if not line.startswith("/"):
|
|
||||||
return text, None
|
|
||||||
|
|
||||||
parts = line.split(maxsplit=1)
|
|
||||||
command = parts[0][1:]
|
|
||||||
if "@" in command:
|
if "@" in command:
|
||||||
command = command.split("@", 1)[0]
|
command = command.split("@", 1)[0]
|
||||||
engine = engine_map.get(command.lower())
|
args_text = rest
|
||||||
if engine is None:
|
if len(lines) > 1:
|
||||||
return text, None
|
tail = "\n".join(lines[1:])
|
||||||
|
args_text = f"{args_text}\n{tail}" if args_text else tail
|
||||||
remainder = parts[1] if len(parts) > 1 else ""
|
return command.lower(), args_text
|
||||||
if remainder:
|
|
||||||
lines[idx] = remainder
|
|
||||||
else:
|
|
||||||
lines.pop(idx)
|
|
||||||
return "\n".join(lines).strip(), engine
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
def _build_bot_commands(runtime: TransportRuntime) -> list[dict[str, str]]:
|
||||||
class ParsedDirectives:
|
|
||||||
prompt: str
|
|
||||||
engine: EngineId | None
|
|
||||||
project: str | None
|
|
||||||
branch: str | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ResolvedMessage:
|
|
||||||
prompt: str
|
|
||||||
resume_token: ResumeToken | None
|
|
||||||
engine_override: EngineId | None
|
|
||||||
context: RunContext | None
|
|
||||||
|
|
||||||
|
|
||||||
class DirectiveError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_directives(
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
engine_ids: tuple[EngineId, ...],
|
|
||||||
projects: ProjectsConfig,
|
|
||||||
) -> ParsedDirectives:
|
|
||||||
if not text:
|
|
||||||
return ParsedDirectives(prompt="", engine=None, project=None, branch=None)
|
|
||||||
|
|
||||||
lines = text.splitlines()
|
|
||||||
idx = next((i for i, line in enumerate(lines) if line.strip()), None)
|
|
||||||
if idx is None:
|
|
||||||
return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
|
|
||||||
|
|
||||||
line = lines[idx].lstrip()
|
|
||||||
tokens = line.split()
|
|
||||||
if not tokens:
|
|
||||||
return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
|
|
||||||
|
|
||||||
engine_map = {engine.lower(): engine for engine in engine_ids}
|
|
||||||
project_map = {alias.lower(): alias for alias in projects.projects}
|
|
||||||
|
|
||||||
engine: EngineId | None = None
|
|
||||||
project: str | None = None
|
|
||||||
branch: str | None = None
|
|
||||||
consumed = 0
|
|
||||||
|
|
||||||
for token in tokens:
|
|
||||||
if token.startswith("/"):
|
|
||||||
name = token[1:]
|
|
||||||
if "@" in name:
|
|
||||||
name = name.split("@", 1)[0]
|
|
||||||
if not name:
|
|
||||||
break
|
|
||||||
key = name.lower()
|
|
||||||
engine_candidate = engine_map.get(key)
|
|
||||||
project_candidate = project_map.get(key)
|
|
||||||
if engine_candidate is not None:
|
|
||||||
if engine is not None:
|
|
||||||
raise DirectiveError("multiple engine directives")
|
|
||||||
engine = engine_candidate
|
|
||||||
consumed += 1
|
|
||||||
continue
|
|
||||||
if project_candidate is not None:
|
|
||||||
if project is not None:
|
|
||||||
raise DirectiveError("multiple project directives")
|
|
||||||
project = project_candidate
|
|
||||||
consumed += 1
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
if token.startswith("@"):
|
|
||||||
value = token[1:]
|
|
||||||
if not value:
|
|
||||||
break
|
|
||||||
if branch is not None:
|
|
||||||
raise DirectiveError("multiple @branch directives")
|
|
||||||
branch = value
|
|
||||||
consumed += 1
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
|
|
||||||
if consumed == 0:
|
|
||||||
return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
|
|
||||||
|
|
||||||
if consumed < len(tokens):
|
|
||||||
remainder = " ".join(tokens[consumed:])
|
|
||||||
lines[idx] = remainder
|
|
||||||
else:
|
|
||||||
lines.pop(idx)
|
|
||||||
|
|
||||||
prompt = "\n".join(lines).strip()
|
|
||||||
return ParsedDirectives(
|
|
||||||
prompt=prompt, engine=engine, project=project, branch=branch
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ctx_line(text: str | None, *, projects: ProjectsConfig) -> RunContext | None:
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
ctx: RunContext | None = None
|
|
||||||
for line in text.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped.startswith("`") and stripped.endswith("`") and len(stripped) > 1:
|
|
||||||
stripped = stripped[1:-1].strip()
|
|
||||||
elif stripped.startswith("`"):
|
|
||||||
stripped = stripped[1:].strip()
|
|
||||||
elif stripped.endswith("`"):
|
|
||||||
stripped = stripped[:-1].strip()
|
|
||||||
if not stripped.lower().startswith("ctx:"):
|
|
||||||
continue
|
|
||||||
content = stripped.split(":", 1)[1].strip()
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
tokens = content.split()
|
|
||||||
if not tokens:
|
|
||||||
continue
|
|
||||||
project = tokens[0]
|
|
||||||
branch = None
|
|
||||||
if len(tokens) >= 2:
|
|
||||||
if tokens[1] == "@" and len(tokens) >= 3:
|
|
||||||
branch = tokens[2]
|
|
||||||
elif tokens[1].startswith("@"):
|
|
||||||
branch = tokens[1][1:]
|
|
||||||
project_key = project.lower()
|
|
||||||
if project_key not in projects.projects:
|
|
||||||
raise DirectiveError(f"unknown project {project!r} in ctx line")
|
|
||||||
ctx = RunContext(project=project_key, branch=branch)
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
def _format_context_line(
|
|
||||||
context: RunContext | None, *, projects: ProjectsConfig
|
|
||||||
) -> str | None:
|
|
||||||
if context is None or context.project is None:
|
|
||||||
return None
|
|
||||||
project_cfg = projects.projects.get(context.project)
|
|
||||||
alias = project_cfg.alias if project_cfg is not None else context.project
|
|
||||||
if context.branch:
|
|
||||||
return f"`ctx: {alias} @ {context.branch}`"
|
|
||||||
return f"`ctx: {alias}`"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_message(
|
|
||||||
*,
|
|
||||||
text: str,
|
|
||||||
reply_text: str | None,
|
|
||||||
router: AutoRouter,
|
|
||||||
projects: ProjectsConfig,
|
|
||||||
) -> ResolvedMessage:
|
|
||||||
directives = _parse_directives(
|
|
||||||
text,
|
|
||||||
engine_ids=router.engine_ids,
|
|
||||||
projects=projects,
|
|
||||||
)
|
|
||||||
reply_ctx = _parse_ctx_line(reply_text, projects=projects)
|
|
||||||
resume_token = router.resolve_resume(directives.prompt, reply_text)
|
|
||||||
|
|
||||||
if resume_token is not None:
|
|
||||||
return ResolvedMessage(
|
|
||||||
prompt=directives.prompt,
|
|
||||||
resume_token=resume_token,
|
|
||||||
engine_override=None,
|
|
||||||
context=reply_ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
if reply_ctx is not None:
|
|
||||||
engine_override = None
|
|
||||||
if reply_ctx.project is not None:
|
|
||||||
project = projects.projects.get(reply_ctx.project)
|
|
||||||
if project is not None and project.default_engine is not None:
|
|
||||||
engine_override = project.default_engine
|
|
||||||
return ResolvedMessage(
|
|
||||||
prompt=directives.prompt,
|
|
||||||
resume_token=None,
|
|
||||||
engine_override=engine_override,
|
|
||||||
context=reply_ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
project_key = directives.project
|
|
||||||
if project_key is None and projects.default_project is not None:
|
|
||||||
project_key = projects.default_project
|
|
||||||
|
|
||||||
context = None
|
|
||||||
if project_key is not None or directives.branch is not None:
|
|
||||||
context = RunContext(project=project_key, branch=directives.branch)
|
|
||||||
|
|
||||||
engine_override = directives.engine
|
|
||||||
if engine_override is None and project_key is not None:
|
|
||||||
project = projects.projects.get(project_key)
|
|
||||||
if project is not None and project.default_engine is not None:
|
|
||||||
engine_override = project.default_engine
|
|
||||||
|
|
||||||
return ResolvedMessage(
|
|
||||||
prompt=directives.prompt,
|
|
||||||
resume_token=None,
|
|
||||||
engine_override=engine_override,
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_bot_commands(
|
|
||||||
router: AutoRouter, projects: ProjectsConfig
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
commands: list[dict[str, str]] = []
|
commands: list[dict[str, str]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for entry in router.available_entries:
|
for engine_id in runtime.available_engine_ids():
|
||||||
cmd = entry.engine.lower()
|
cmd = engine_id.lower()
|
||||||
if cmd in seen:
|
if cmd in seen:
|
||||||
continue
|
continue
|
||||||
commands.append({"command": cmd, "description": f"use agent: {cmd}"})
|
commands.append({"command": cmd, "description": f"use agent: {cmd}"})
|
||||||
seen.add(cmd)
|
seen.add(cmd)
|
||||||
for alias, project in projects.projects.items():
|
for alias in runtime.project_aliases():
|
||||||
cmd = alias.lower()
|
cmd = alias.lower()
|
||||||
if cmd in seen:
|
if cmd in seen:
|
||||||
continue
|
continue
|
||||||
if not _is_valid_bot_command(cmd):
|
if not is_valid_id(cmd):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"startup.command_menu.skip_project",
|
"startup.command_menu.skip_project",
|
||||||
alias=project.alias,
|
alias=alias,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
commands.append({"command": cmd, "description": f"work on: {cmd}"})
|
commands.append({"command": cmd, "description": f"work on: {cmd}"})
|
||||||
seen.add(cmd)
|
seen.add(cmd)
|
||||||
|
allowlist = runtime.allowlist
|
||||||
|
for ep in list_entrypoints(
|
||||||
|
COMMAND_GROUP,
|
||||||
|
allowlist=allowlist,
|
||||||
|
reserved_ids=RESERVED_COMMAND_IDS,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
backend = get_command(ep.name, allowlist=allowlist)
|
||||||
|
except ConfigError as exc:
|
||||||
|
logger.info(
|
||||||
|
"startup.command_menu.skip_command",
|
||||||
|
command=ep.name,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cmd = backend.id.lower()
|
||||||
|
if cmd in seen:
|
||||||
|
continue
|
||||||
|
if not is_valid_id(cmd):
|
||||||
|
logger.debug(
|
||||||
|
"startup.command_menu.skip_command_id",
|
||||||
|
command=cmd,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
description = backend.description or f"command: {cmd}"
|
||||||
|
commands.append({"command": cmd, "description": description})
|
||||||
|
seen.add(cmd)
|
||||||
if "cancel" not in seen:
|
if "cancel" not in seen:
|
||||||
commands.append({"command": "cancel", "description": "cancel run"})
|
commands.append({"command": "cancel", "description": "cancel run"})
|
||||||
if len(commands) > _MAX_BOT_COMMANDS:
|
if len(commands) > _MAX_BOT_COMMANDS:
|
||||||
@@ -325,7 +138,7 @@ def _build_bot_commands(
|
|||||||
|
|
||||||
|
|
||||||
async def _set_command_menu(cfg: TelegramBridgeConfig) -> None:
|
async def _set_command_menu(cfg: TelegramBridgeConfig) -> None:
|
||||||
commands = _build_bot_commands(cfg.router, cfg.projects)
|
commands = _build_bot_commands(cfg.runtime)
|
||||||
if not commands:
|
if not commands:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -468,11 +281,10 @@ class TelegramTransport:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TelegramBridgeConfig:
|
class TelegramBridgeConfig:
|
||||||
bot: BotClient
|
bot: BotClient
|
||||||
router: AutoRouter
|
runtime: TransportRuntime
|
||||||
chat_id: int
|
chat_id: int
|
||||||
startup_msg: str
|
startup_msg: str
|
||||||
exec_cfg: ExecBridgeConfig
|
exec_cfg: ExecBridgeConfig
|
||||||
projects: ProjectsConfig = field(default_factory=empty_projects_config)
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_plain(
|
async def _send_plain(
|
||||||
@@ -524,7 +336,7 @@ async def _drain_backlog(cfg: TelegramBridgeConfig, offset: int | None) -> int |
|
|||||||
|
|
||||||
async def poll_updates(
|
async def poll_updates(
|
||||||
cfg: TelegramBridgeConfig,
|
cfg: TelegramBridgeConfig,
|
||||||
) -> AsyncIterator[TransportIncomingMessage]:
|
) -> AsyncIterator[TelegramIncomingMessage]:
|
||||||
offset: int | None = None
|
offset: int | None = None
|
||||||
offset = await _drain_backlog(cfg, offset)
|
offset = await _drain_backlog(cfg, offset)
|
||||||
await _send_startup(cfg)
|
await _send_startup(cfg)
|
||||||
@@ -535,7 +347,7 @@ async def poll_updates(
|
|||||||
|
|
||||||
async def _handle_cancel(
|
async def _handle_cancel(
|
||||||
cfg: TelegramBridgeConfig,
|
cfg: TelegramBridgeConfig,
|
||||||
msg: TransportIncomingMessage,
|
msg: TelegramIncomingMessage,
|
||||||
running_tasks: RunningTasks,
|
running_tasks: RunningTasks,
|
||||||
) -> None:
|
) -> None:
|
||||||
chat_id = msg.chat_id
|
chat_id = msg.chat_id
|
||||||
@@ -623,7 +435,7 @@ async def _send_with_resume(
|
|||||||
|
|
||||||
|
|
||||||
async def _send_runner_unavailable(
|
async def _send_runner_unavailable(
|
||||||
cfg: TelegramBridgeConfig,
|
exec_cfg: ExecBridgeConfig,
|
||||||
*,
|
*,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
user_msg_id: int,
|
user_msg_id: int,
|
||||||
@@ -634,33 +446,25 @@ async def _send_runner_unavailable(
|
|||||||
tracker = ProgressTracker(engine=runner.engine)
|
tracker = ProgressTracker(engine=runner.engine)
|
||||||
tracker.set_resume(resume_token)
|
tracker.set_resume(resume_token)
|
||||||
state = tracker.snapshot(resume_formatter=runner.format_resume)
|
state = tracker.snapshot(resume_formatter=runner.format_resume)
|
||||||
message = cfg.exec_cfg.presenter.render_final(
|
message = exec_cfg.presenter.render_final(
|
||||||
state,
|
state,
|
||||||
elapsed_s=0.0,
|
elapsed_s=0.0,
|
||||||
status="error",
|
status="error",
|
||||||
answer=f"error:\n{reason}",
|
answer=f"error:\n{reason}",
|
||||||
)
|
)
|
||||||
reply_to = MessageRef(channel_id=chat_id, message_id=user_msg_id)
|
reply_to = MessageRef(channel_id=chat_id, message_id=user_msg_id)
|
||||||
await cfg.exec_cfg.transport.send(
|
await exec_cfg.transport.send(
|
||||||
channel_id=chat_id,
|
channel_id=chat_id,
|
||||||
message=message,
|
message=message,
|
||||||
options=SendOptions(reply_to=reply_to, notify=True),
|
options=SendOptions(reply_to=reply_to, notify=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_main_loop(
|
async def _run_engine(
|
||||||
cfg: TelegramBridgeConfig,
|
*,
|
||||||
poller: Callable[
|
exec_cfg: ExecBridgeConfig,
|
||||||
[TelegramBridgeConfig], AsyncIterator[TransportIncomingMessage]
|
runtime: TransportRuntime,
|
||||||
] = poll_updates,
|
running_tasks: RunningTasks | None,
|
||||||
) -> None:
|
|
||||||
running_tasks: RunningTasks = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _set_command_menu(cfg)
|
|
||||||
async with anyio.create_task_group() as tg:
|
|
||||||
|
|
||||||
async def run_job(
|
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
user_msg_id: int,
|
user_msg_id: int,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -673,14 +477,13 @@ async def run_main_loop(
|
|||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
entry = (
|
entry = runtime.resolve_runner(
|
||||||
cfg.router.entry_for_engine(engine_override)
|
resume_token=resume_token,
|
||||||
if resume_token is None
|
engine_override=engine_override,
|
||||||
else cfg.router.entry_for(resume_token)
|
|
||||||
)
|
)
|
||||||
except RunnerUnavailableError as exc:
|
except RunnerUnavailableError as exc:
|
||||||
await _send_plain(
|
await _send_plain(
|
||||||
cfg.exec_cfg.transport,
|
exec_cfg.transport,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_msg_id=user_msg_id,
|
user_msg_id=user_msg_id,
|
||||||
text=f"error:\n{exc}",
|
text=f"error:\n{exc}",
|
||||||
@@ -689,7 +492,7 @@ async def run_main_loop(
|
|||||||
if not entry.available:
|
if not entry.available:
|
||||||
reason = entry.issue or "engine unavailable"
|
reason = entry.issue or "engine unavailable"
|
||||||
await _send_runner_unavailable(
|
await _send_runner_unavailable(
|
||||||
cfg,
|
exec_cfg,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_msg_id=user_msg_id,
|
user_msg_id=user_msg_id,
|
||||||
resume_token=resume_token,
|
resume_token=resume_token,
|
||||||
@@ -698,10 +501,10 @@ async def run_main_loop(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
cwd = resolve_run_cwd(context, projects=cfg.projects)
|
cwd = runtime.resolve_run_cwd(context)
|
||||||
except WorktreeError as exc:
|
except ConfigError as exc:
|
||||||
await _send_plain(
|
await _send_plain(
|
||||||
cfg.exec_cfg.transport,
|
exec_cfg.transport,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_msg_id=user_msg_id,
|
user_msg_id=user_msg_id,
|
||||||
text=f"error:\n{exc}",
|
text=f"error:\n{exc}",
|
||||||
@@ -721,9 +524,7 @@ async def run_main_loop(
|
|||||||
if cwd is not None:
|
if cwd is not None:
|
||||||
run_fields["cwd"] = str(cwd)
|
run_fields["cwd"] = str(cwd)
|
||||||
bind_run_context(**run_fields)
|
bind_run_context(**run_fields)
|
||||||
context_line = _format_context_line(
|
context_line = runtime.format_context_line(context)
|
||||||
context, projects=cfg.projects
|
|
||||||
)
|
|
||||||
incoming = RunnerIncomingMessage(
|
incoming = RunnerIncomingMessage(
|
||||||
channel_id=chat_id,
|
channel_id=chat_id,
|
||||||
message_id=user_msg_id,
|
message_id=user_msg_id,
|
||||||
@@ -731,13 +532,13 @@ async def run_main_loop(
|
|||||||
reply_to=reply_ref,
|
reply_to=reply_ref,
|
||||||
)
|
)
|
||||||
await handle_message(
|
await handle_message(
|
||||||
cfg.exec_cfg,
|
exec_cfg,
|
||||||
runner=entry.runner,
|
runner=entry.runner,
|
||||||
incoming=incoming,
|
incoming=incoming,
|
||||||
resume_token=resume_token,
|
resume_token=resume_token,
|
||||||
context=context,
|
context=context,
|
||||||
context_line=context_line,
|
context_line=context_line,
|
||||||
strip_resume_line=cfg.router.is_resume_line,
|
strip_resume_line=runtime.is_resume_line,
|
||||||
running_tasks=running_tasks,
|
running_tasks=running_tasks,
|
||||||
on_thread_known=on_thread_known,
|
on_thread_known=on_thread_known,
|
||||||
)
|
)
|
||||||
@@ -752,6 +553,265 @@ async def run_main_loop(
|
|||||||
finally:
|
finally:
|
||||||
clear_context()
|
clear_context()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_command_args(text: str) -> tuple[str, ...]:
|
||||||
|
if not text.strip():
|
||||||
|
return ()
|
||||||
|
try:
|
||||||
|
return tuple(shlex.split(text))
|
||||||
|
except ValueError:
|
||||||
|
return tuple(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
class _CaptureTransport:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._next_id = 1
|
||||||
|
self.last_message: RenderedMessage | None = None
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel_id: int | str,
|
||||||
|
message: RenderedMessage,
|
||||||
|
options: SendOptions | None = None,
|
||||||
|
) -> MessageRef:
|
||||||
|
_ = options
|
||||||
|
ref = MessageRef(channel_id=channel_id, message_id=self._next_id)
|
||||||
|
self._next_id += 1
|
||||||
|
self.last_message = message
|
||||||
|
return ref
|
||||||
|
|
||||||
|
async def edit(
|
||||||
|
self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True
|
||||||
|
) -> MessageRef:
|
||||||
|
_ = ref, wait
|
||||||
|
self.last_message = message
|
||||||
|
return ref
|
||||||
|
|
||||||
|
async def delete(self, *, ref: MessageRef) -> bool:
|
||||||
|
_ = ref
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _TelegramCommandExecutor(CommandExecutor):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
exec_cfg: ExecBridgeConfig,
|
||||||
|
runtime: TransportRuntime,
|
||||||
|
running_tasks: RunningTasks,
|
||||||
|
scheduler: ThreadScheduler,
|
||||||
|
chat_id: int,
|
||||||
|
user_msg_id: int,
|
||||||
|
) -> None:
|
||||||
|
self._exec_cfg = exec_cfg
|
||||||
|
self._runtime = runtime
|
||||||
|
self._running_tasks = running_tasks
|
||||||
|
self._scheduler = scheduler
|
||||||
|
self._chat_id = chat_id
|
||||||
|
self._user_msg_id = user_msg_id
|
||||||
|
self._reply_ref = MessageRef(channel_id=chat_id, message_id=user_msg_id)
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
message: RenderedMessage | str,
|
||||||
|
*,
|
||||||
|
reply_to: MessageRef | None = None,
|
||||||
|
notify: bool = True,
|
||||||
|
) -> MessageRef | None:
|
||||||
|
rendered = (
|
||||||
|
message
|
||||||
|
if isinstance(message, RenderedMessage)
|
||||||
|
else RenderedMessage(text=message)
|
||||||
|
)
|
||||||
|
reply_ref = self._reply_ref if reply_to is None else reply_to
|
||||||
|
return await self._exec_cfg.transport.send(
|
||||||
|
channel_id=self._chat_id,
|
||||||
|
message=rendered,
|
||||||
|
options=SendOptions(reply_to=reply_ref, notify=notify),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_one(
|
||||||
|
self, request: RunRequest, *, mode: RunMode = "emit"
|
||||||
|
) -> RunResult:
|
||||||
|
engine = self._runtime.resolve_engine(
|
||||||
|
engine_override=request.engine,
|
||||||
|
context=request.context,
|
||||||
|
)
|
||||||
|
if mode == "capture":
|
||||||
|
capture = _CaptureTransport()
|
||||||
|
exec_cfg = ExecBridgeConfig(
|
||||||
|
transport=capture,
|
||||||
|
presenter=self._exec_cfg.presenter,
|
||||||
|
final_notify=False,
|
||||||
|
)
|
||||||
|
await _run_engine(
|
||||||
|
exec_cfg=exec_cfg,
|
||||||
|
runtime=self._runtime,
|
||||||
|
running_tasks={},
|
||||||
|
chat_id=self._chat_id,
|
||||||
|
user_msg_id=self._user_msg_id,
|
||||||
|
text=request.prompt,
|
||||||
|
resume_token=None,
|
||||||
|
context=request.context,
|
||||||
|
reply_ref=self._reply_ref,
|
||||||
|
on_thread_known=None,
|
||||||
|
engine_override=engine,
|
||||||
|
)
|
||||||
|
return RunResult(engine=engine, message=capture.last_message)
|
||||||
|
await _run_engine(
|
||||||
|
exec_cfg=self._exec_cfg,
|
||||||
|
runtime=self._runtime,
|
||||||
|
running_tasks=self._running_tasks,
|
||||||
|
chat_id=self._chat_id,
|
||||||
|
user_msg_id=self._user_msg_id,
|
||||||
|
text=request.prompt,
|
||||||
|
resume_token=None,
|
||||||
|
context=request.context,
|
||||||
|
reply_ref=self._reply_ref,
|
||||||
|
on_thread_known=self._scheduler.note_thread_known,
|
||||||
|
engine_override=engine,
|
||||||
|
)
|
||||||
|
return RunResult(engine=engine, message=None)
|
||||||
|
|
||||||
|
async def run_many(
|
||||||
|
self,
|
||||||
|
requests: Sequence[RunRequest],
|
||||||
|
*,
|
||||||
|
mode: RunMode = "emit",
|
||||||
|
parallel: bool = False,
|
||||||
|
) -> list[RunResult]:
|
||||||
|
if not parallel:
|
||||||
|
return [await self.run_one(request, mode=mode) for request in requests]
|
||||||
|
results: list[RunResult | None] = [None] * len(requests)
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
|
||||||
|
async def run_idx(idx: int, request: RunRequest) -> None:
|
||||||
|
results[idx] = await self.run_one(request, mode=mode)
|
||||||
|
|
||||||
|
for idx, request in enumerate(requests):
|
||||||
|
tg.start_soon(run_idx, idx, request)
|
||||||
|
|
||||||
|
return [result for result in results if result is not None]
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_command(
|
||||||
|
cfg: TelegramBridgeConfig,
|
||||||
|
msg: TelegramIncomingMessage,
|
||||||
|
command_id: str,
|
||||||
|
args_text: str,
|
||||||
|
running_tasks: RunningTasks,
|
||||||
|
scheduler: ThreadScheduler,
|
||||||
|
) -> None:
|
||||||
|
allowlist = cfg.runtime.allowlist
|
||||||
|
chat_id = msg.chat_id
|
||||||
|
user_msg_id = msg.message_id
|
||||||
|
reply_ref = (
|
||||||
|
MessageRef(channel_id=chat_id, message_id=msg.reply_to_message_id)
|
||||||
|
if msg.reply_to_message_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
executor = _TelegramCommandExecutor(
|
||||||
|
exec_cfg=cfg.exec_cfg,
|
||||||
|
runtime=cfg.runtime,
|
||||||
|
running_tasks=running_tasks,
|
||||||
|
scheduler=scheduler,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_msg_id=user_msg_id,
|
||||||
|
)
|
||||||
|
message_ref = MessageRef(channel_id=chat_id, message_id=user_msg_id)
|
||||||
|
try:
|
||||||
|
backend = get_command(command_id, allowlist=allowlist, required=False)
|
||||||
|
except ConfigError as exc:
|
||||||
|
await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True)
|
||||||
|
return
|
||||||
|
if backend is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
plugin_config = cfg.runtime.plugin_config(command_id)
|
||||||
|
except ConfigError as exc:
|
||||||
|
await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True)
|
||||||
|
return
|
||||||
|
ctx = CommandContext(
|
||||||
|
command=command_id,
|
||||||
|
text=msg.text,
|
||||||
|
args_text=args_text,
|
||||||
|
args=_split_command_args(args_text),
|
||||||
|
message=message_ref,
|
||||||
|
reply_to=reply_ref,
|
||||||
|
reply_text=msg.reply_to_text,
|
||||||
|
config_path=cfg.runtime.config_path,
|
||||||
|
plugin_config=plugin_config,
|
||||||
|
runtime=cfg.runtime,
|
||||||
|
executor=executor,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await backend.handle(ctx)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"command.failed",
|
||||||
|
command=command_id,
|
||||||
|
error=str(exc),
|
||||||
|
error_type=exc.__class__.__name__,
|
||||||
|
)
|
||||||
|
await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True)
|
||||||
|
return
|
||||||
|
if result is not None:
|
||||||
|
reply_to = message_ref if result.reply_to is None else result.reply_to
|
||||||
|
await executor.send(result.text, reply_to=reply_to, notify=result.notify)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def run_main_loop(
|
||||||
|
cfg: TelegramBridgeConfig,
|
||||||
|
poller: Callable[[TelegramBridgeConfig], AsyncIterator[TelegramIncomingMessage]] = (
|
||||||
|
poll_updates
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
running_tasks: RunningTasks = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _set_command_menu(cfg)
|
||||||
|
allowlist = cfg.runtime.allowlist
|
||||||
|
command_ids = {
|
||||||
|
command_id.lower() for command_id in list_command_ids(allowlist=allowlist)
|
||||||
|
}
|
||||||
|
reserved_commands = {
|
||||||
|
*{engine.lower() for engine in cfg.runtime.engine_ids},
|
||||||
|
*{alias.lower() for alias in cfg.runtime.project_aliases()},
|
||||||
|
*RESERVED_COMMAND_IDS,
|
||||||
|
}
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
|
||||||
|
async def run_job(
|
||||||
|
chat_id: int,
|
||||||
|
user_msg_id: int,
|
||||||
|
text: str,
|
||||||
|
resume_token: ResumeToken | None,
|
||||||
|
context: RunContext | None,
|
||||||
|
reply_ref: MessageRef | None = None,
|
||||||
|
on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]]
|
||||||
|
| None = None,
|
||||||
|
engine_override: EngineId | None = None,
|
||||||
|
) -> None:
|
||||||
|
await _run_engine(
|
||||||
|
exec_cfg=cfg.exec_cfg,
|
||||||
|
runtime=cfg.runtime,
|
||||||
|
running_tasks=running_tasks,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_msg_id=user_msg_id,
|
||||||
|
text=text,
|
||||||
|
resume_token=resume_token,
|
||||||
|
context=context,
|
||||||
|
reply_ref=reply_ref,
|
||||||
|
on_thread_known=on_thread_known,
|
||||||
|
engine_override=engine_override,
|
||||||
|
)
|
||||||
|
|
||||||
async def run_thread_job(job: ThreadJob) -> None:
|
async def run_thread_job(job: ThreadJob) -> None:
|
||||||
await run_job(
|
await run_job(
|
||||||
job.chat_id,
|
job.chat_id,
|
||||||
@@ -779,13 +839,29 @@ async def run_main_loop(
|
|||||||
tg.start_soon(_handle_cancel, cfg, msg, running_tasks)
|
tg.start_soon(_handle_cancel, cfg, msg, running_tasks)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
command_id, args_text = _parse_slash_command(text)
|
||||||
|
if command_id is not None and command_id not in reserved_commands:
|
||||||
|
if command_id not in command_ids:
|
||||||
|
command_ids = {
|
||||||
|
cid.lower() for cid in list_command_ids(allowlist=allowlist)
|
||||||
|
}
|
||||||
|
if command_id in command_ids:
|
||||||
|
tg.start_soon(
|
||||||
|
_dispatch_command,
|
||||||
|
cfg,
|
||||||
|
msg,
|
||||||
|
command_id,
|
||||||
|
args_text,
|
||||||
|
running_tasks,
|
||||||
|
scheduler,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
reply_text = msg.reply_to_text
|
reply_text = msg.reply_to_text
|
||||||
try:
|
try:
|
||||||
resolved = _resolve_message(
|
resolved = cfg.runtime.resolve_message(
|
||||||
text=text,
|
text=text,
|
||||||
reply_text=reply_text,
|
reply_text=reply_text,
|
||||||
router=cfg.router,
|
|
||||||
projects=cfg.projects,
|
|
||||||
)
|
)
|
||||||
except DirectiveError as exc:
|
except DirectiveError as exc:
|
||||||
await _send_plain(
|
await _send_plain(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import httpx
|
|||||||
import anyio
|
import anyio
|
||||||
|
|
||||||
from ..logging import get_logger
|
from ..logging import get_logger
|
||||||
from ..transport import IncomingMessage
|
from .types import TelegramIncomingMessage
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ def is_group_chat_id(chat_id: int) -> bool:
|
|||||||
|
|
||||||
def parse_incoming_update(
|
def parse_incoming_update(
|
||||||
update: dict[str, Any], *, chat_id: int
|
update: dict[str, Any], *, chat_id: int
|
||||||
) -> IncomingMessage | None:
|
) -> TelegramIncomingMessage | None:
|
||||||
msg = update.get("message")
|
msg = update.get("message")
|
||||||
if not isinstance(msg, dict):
|
if not isinstance(msg, dict):
|
||||||
return None
|
return None
|
||||||
@@ -79,7 +79,7 @@ def parse_incoming_update(
|
|||||||
if isinstance(sender, dict) and isinstance(sender.get("id"), int)
|
if isinstance(sender, dict) and isinstance(sender.get("id"), int)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
return IncomingMessage(
|
return TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=msg_chat_id,
|
chat_id=msg_chat_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
@@ -96,7 +96,7 @@ async def poll_incoming(
|
|||||||
*,
|
*,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
offset: int | None = None,
|
offset: int | None = None,
|
||||||
) -> AsyncIterator[IncomingMessage]:
|
) -> AsyncIterator[TelegramIncomingMessage]:
|
||||||
while True:
|
while True:
|
||||||
updates = await bot.get_updates(
|
updates = await bot.get_updates(
|
||||||
offset=offset, timeout_s=50, allowed_updates=["message"]
|
offset=offset, timeout_s=50, allowed_updates=["message"]
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TelegramIncomingMessage:
|
||||||
|
transport: str
|
||||||
|
chat_id: int
|
||||||
|
message_id: int
|
||||||
|
text: str
|
||||||
|
reply_to_message_id: int | None
|
||||||
|
reply_to_text: str | None
|
||||||
|
sender_id: int | None
|
||||||
|
raw: dict[str, Any] | None = None
|
||||||
@@ -7,18 +7,6 @@ ChannelId: TypeAlias = int | str
|
|||||||
MessageId: TypeAlias = int | str
|
MessageId: TypeAlias = int | str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class IncomingMessage:
|
|
||||||
transport: str
|
|
||||||
chat_id: int
|
|
||||||
message_id: int
|
|
||||||
text: str
|
|
||||||
reply_to_message_id: int | None
|
|
||||||
reply_to_text: str | None
|
|
||||||
sender_id: int | None
|
|
||||||
raw: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class MessageRef:
|
class MessageRef:
|
||||||
channel_id: ChannelId
|
channel_id: ChannelId
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import ConfigError, ProjectsConfig
|
||||||
|
from .context import RunContext
|
||||||
|
from .directives import format_context_line, parse_context_line, parse_directives
|
||||||
|
from .model import EngineId, ResumeToken
|
||||||
|
from .plugins import normalize_allowlist
|
||||||
|
from .router import AutoRouter
|
||||||
|
from .runner import Runner
|
||||||
|
from .worktrees import WorktreeError, resolve_run_cwd
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ResolvedMessage:
|
||||||
|
prompt: str
|
||||||
|
resume_token: ResumeToken | None
|
||||||
|
engine_override: EngineId | None
|
||||||
|
context: RunContext | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ResolvedRunner:
|
||||||
|
engine: EngineId
|
||||||
|
runner: Runner
|
||||||
|
available: bool
|
||||||
|
issue: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransportRuntime:
|
||||||
|
__slots__ = (
|
||||||
|
"_router",
|
||||||
|
"_projects",
|
||||||
|
"_allowlist",
|
||||||
|
"_config_path",
|
||||||
|
"_plugin_configs",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
router: AutoRouter,
|
||||||
|
projects: ProjectsConfig,
|
||||||
|
allowlist: Iterable[str] | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
plugin_configs: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._router = router
|
||||||
|
self._projects = projects
|
||||||
|
self._allowlist = normalize_allowlist(allowlist)
|
||||||
|
self._config_path = config_path
|
||||||
|
self._plugin_configs = dict(plugin_configs or {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_engine(self) -> EngineId:
|
||||||
|
return self._router.default_engine
|
||||||
|
|
||||||
|
def resolve_engine(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
engine_override: EngineId | None,
|
||||||
|
context: RunContext | None,
|
||||||
|
) -> EngineId:
|
||||||
|
if engine_override is not None:
|
||||||
|
return engine_override
|
||||||
|
if context is None or context.project is None:
|
||||||
|
return self._router.default_engine
|
||||||
|
project = self._projects.projects.get(context.project)
|
||||||
|
if project is None:
|
||||||
|
return self._router.default_engine
|
||||||
|
return project.default_engine or self._router.default_engine
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engine_ids(self) -> tuple[EngineId, ...]:
|
||||||
|
return self._router.engine_ids
|
||||||
|
|
||||||
|
def available_engine_ids(self) -> tuple[EngineId, ...]:
|
||||||
|
return tuple(entry.engine for entry in self._router.available_entries)
|
||||||
|
|
||||||
|
def missing_engine_ids(self) -> tuple[EngineId, ...]:
|
||||||
|
return tuple(
|
||||||
|
entry.engine for entry in self._router.entries if not entry.available
|
||||||
|
)
|
||||||
|
|
||||||
|
def project_aliases(self) -> tuple[str, ...]:
|
||||||
|
return tuple(project.alias for project in self._projects.projects.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowlist(self) -> set[str] | None:
|
||||||
|
return self._allowlist
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_path(self) -> Path | None:
|
||||||
|
return self._config_path
|
||||||
|
|
||||||
|
def plugin_config(self, plugin_id: str) -> dict[str, Any]:
|
||||||
|
if not self._plugin_configs:
|
||||||
|
return {}
|
||||||
|
raw = self._plugin_configs.get(plugin_id)
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
path = self._config_path or Path("<config>")
|
||||||
|
raise ConfigError(
|
||||||
|
f"Invalid `plugins.{plugin_id}` in {path}; expected a table."
|
||||||
|
)
|
||||||
|
return dict(raw)
|
||||||
|
|
||||||
|
def resolve_message(self, *, text: str, reply_text: str | None) -> ResolvedMessage:
|
||||||
|
directives = parse_directives(
|
||||||
|
text,
|
||||||
|
engine_ids=self._router.engine_ids,
|
||||||
|
projects=self._projects,
|
||||||
|
)
|
||||||
|
reply_ctx = parse_context_line(reply_text, projects=self._projects)
|
||||||
|
resume_token = self._router.resolve_resume(directives.prompt, reply_text)
|
||||||
|
|
||||||
|
if resume_token is not None:
|
||||||
|
return ResolvedMessage(
|
||||||
|
prompt=directives.prompt,
|
||||||
|
resume_token=resume_token,
|
||||||
|
engine_override=None,
|
||||||
|
context=reply_ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply_ctx is not None:
|
||||||
|
engine_override = None
|
||||||
|
if reply_ctx.project is not None:
|
||||||
|
project = self._projects.projects.get(reply_ctx.project)
|
||||||
|
if project is not None and project.default_engine is not None:
|
||||||
|
engine_override = project.default_engine
|
||||||
|
return ResolvedMessage(
|
||||||
|
prompt=directives.prompt,
|
||||||
|
resume_token=None,
|
||||||
|
engine_override=engine_override,
|
||||||
|
context=reply_ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
project_key = directives.project
|
||||||
|
if project_key is None and self._projects.default_project is not None:
|
||||||
|
project_key = self._projects.default_project
|
||||||
|
|
||||||
|
context = None
|
||||||
|
if project_key is not None or directives.branch is not None:
|
||||||
|
context = RunContext(project=project_key, branch=directives.branch)
|
||||||
|
|
||||||
|
engine_override = directives.engine
|
||||||
|
if engine_override is None and project_key is not None:
|
||||||
|
project = self._projects.projects.get(project_key)
|
||||||
|
if project is not None and project.default_engine is not None:
|
||||||
|
engine_override = project.default_engine
|
||||||
|
|
||||||
|
return ResolvedMessage(
|
||||||
|
prompt=directives.prompt,
|
||||||
|
resume_token=None,
|
||||||
|
engine_override=engine_override,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_runner(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resume_token: ResumeToken | None,
|
||||||
|
engine_override: EngineId | None,
|
||||||
|
) -> ResolvedRunner:
|
||||||
|
entry = (
|
||||||
|
self._router.entry_for_engine(engine_override)
|
||||||
|
if resume_token is None
|
||||||
|
else self._router.entry_for(resume_token)
|
||||||
|
)
|
||||||
|
return ResolvedRunner(
|
||||||
|
engine=entry.engine,
|
||||||
|
runner=entry.runner,
|
||||||
|
available=entry.available,
|
||||||
|
issue=entry.issue,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_resume_line(self, line: str) -> bool:
|
||||||
|
return self._router.is_resume_line(line)
|
||||||
|
|
||||||
|
def resolve_run_cwd(self, context: RunContext | None) -> Path | None:
|
||||||
|
try:
|
||||||
|
return resolve_run_cwd(context, projects=self._projects)
|
||||||
|
except WorktreeError as exc:
|
||||||
|
raise ConfigError(str(exc)) from exc
|
||||||
|
|
||||||
|
def format_context_line(self, context: RunContext | None) -> str | None:
|
||||||
|
return format_context_line(context, projects=self._projects)
|
||||||
+42
-38
@@ -2,12 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
from typing import Iterable, Protocol, runtime_checkable
|
||||||
|
|
||||||
from .backends import EngineBackend, SetupIssue
|
from .backends import EngineBackend, SetupIssue
|
||||||
from .config import ConfigError, ProjectsConfig
|
from .config import ConfigError
|
||||||
from .router import AutoRouter
|
from .plugins import (
|
||||||
from .settings import TakopiSettings
|
PluginLoadFailed,
|
||||||
|
PluginNotFound,
|
||||||
|
TRANSPORT_GROUP,
|
||||||
|
load_entrypoint,
|
||||||
|
list_ids,
|
||||||
|
)
|
||||||
|
from .transport_runtime import TransportRuntime
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -20,6 +26,7 @@ class SetupResult:
|
|||||||
return not self.issues
|
return not self.issues
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
class TransportBackend(Protocol):
|
class TransportBackend(Protocol):
|
||||||
id: str
|
id: str
|
||||||
description: str
|
description: str
|
||||||
@@ -34,53 +41,50 @@ class TransportBackend(Protocol):
|
|||||||
def interactive_setup(self, *, force: bool) -> bool: ...
|
def interactive_setup(self, *, force: bool) -> bool: ...
|
||||||
|
|
||||||
def lock_token(
|
def lock_token(
|
||||||
self, *, settings: TakopiSettings, config_path: Path
|
self, *, transport_config: dict[str, object], config_path: Path
|
||||||
) -> str | None: ...
|
) -> str | None: ...
|
||||||
|
|
||||||
def build_and_run(
|
def build_and_run(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
settings: TakopiSettings,
|
transport_config: dict[str, object],
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
router: AutoRouter,
|
runtime: TransportRuntime,
|
||||||
projects: ProjectsConfig,
|
|
||||||
final_notify: bool,
|
final_notify: bool,
|
||||||
default_engine_override: str | None,
|
default_engine_override: str | None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
_registry: dict[str, TransportBackend] = {}
|
def _validate_transport_backend(backend: object, ep) -> None:
|
||||||
_builtins_loaded = False
|
if not isinstance(backend, TransportBackend):
|
||||||
|
raise TypeError(f"{ep.value} is not a TransportBackend")
|
||||||
|
if backend.id != ep.name:
|
||||||
|
raise ValueError(
|
||||||
|
f"{ep.value} transport id {backend.id!r} does not match entrypoint {ep.name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_transport(backend: TransportBackend) -> None:
|
def get_transport(
|
||||||
existing = _registry.get(backend.id)
|
transport_id: str, *, allowlist: Iterable[str] | None = None
|
||||||
if existing is not None and existing is not backend:
|
) -> TransportBackend:
|
||||||
raise ConfigError(f"Transport {backend.id!r} is already registered.")
|
|
||||||
_registry[backend.id] = backend
|
|
||||||
|
|
||||||
|
|
||||||
def register_builtin_transports() -> None:
|
|
||||||
global _builtins_loaded
|
|
||||||
if _builtins_loaded:
|
|
||||||
return
|
|
||||||
from .telegram.backend import telegram_backend
|
|
||||||
|
|
||||||
register_transport(telegram_backend)
|
|
||||||
_builtins_loaded = True
|
|
||||||
|
|
||||||
|
|
||||||
def get_transport(transport_id: str) -> TransportBackend:
|
|
||||||
register_builtin_transports()
|
|
||||||
try:
|
try:
|
||||||
return _registry[transport_id]
|
backend = load_entrypoint(
|
||||||
except KeyError:
|
TRANSPORT_GROUP,
|
||||||
available = ", ".join(sorted(_registry))
|
transport_id,
|
||||||
raise ConfigError(
|
allowlist=allowlist,
|
||||||
f"Unknown transport {transport_id!r}. Available: {available}."
|
validator=_validate_transport_backend,
|
||||||
) from None
|
)
|
||||||
|
except PluginNotFound as exc:
|
||||||
|
if exc.available:
|
||||||
|
available = ", ".join(exc.available)
|
||||||
|
message = f"Unknown transport {transport_id!r}. Available: {available}."
|
||||||
|
else:
|
||||||
|
message = f"Unknown transport {transport_id!r}."
|
||||||
|
raise ConfigError(message) from exc
|
||||||
|
except PluginLoadFailed as exc:
|
||||||
|
raise ConfigError(f"Failed to load transport {transport_id!r}: {exc}") from exc
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
def list_transports() -> list[str]:
|
def list_transports(*, allowlist: Iterable[str] | None = None) -> list[str]:
|
||||||
register_builtin_transports()
|
return list_ids(TRANSPORT_GROUP, allowlist=allowlist)
|
||||||
return sorted(_registry)
|
|
||||||
|
|||||||
@@ -9,3 +9,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def anyio_backend() -> str:
|
def anyio_backend() -> str:
|
||||||
return "asyncio"
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_plugins_state() -> None:
|
||||||
|
import takopi.plugins as plugins
|
||||||
|
|
||||||
|
plugins.reset_plugin_state()
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FakeDist:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEntryPoint:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
value: str,
|
||||||
|
group: str,
|
||||||
|
*,
|
||||||
|
loader: Callable[[], Any] | None = None,
|
||||||
|
dist_name: str | None = "takopi",
|
||||||
|
) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.value = value
|
||||||
|
self.group = group
|
||||||
|
self._loader = loader or (lambda: None)
|
||||||
|
self.dist = FakeDist(dist_name) if dist_name else None
|
||||||
|
|
||||||
|
def load(self) -> Any:
|
||||||
|
return self._loader()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEntryPoints(list):
|
||||||
|
def select(self, *, group: str) -> list[FakeEntryPoint]:
|
||||||
|
return [ep for ep in self if ep.group == group]
|
||||||
|
|
||||||
|
def get(self, group: str, default: Iterable[Any] | None = None) -> list[Any]:
|
||||||
|
_ = default
|
||||||
|
return [ep for ep in self if ep.group == group]
|
||||||
|
|
||||||
|
|
||||||
|
def install_entrypoints(monkeypatch, entrypoints: Iterable[FakeEntryPoint]) -> None:
|
||||||
|
from takopi import plugins
|
||||||
|
|
||||||
|
def _entry_points() -> FakeEntryPoints:
|
||||||
|
return FakeEntryPoints(entrypoints)
|
||||||
|
|
||||||
|
monkeypatch.setattr(plugins, "entry_points", _entry_points)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from takopi import commands, plugins
|
||||||
|
from takopi.config import ConfigError
|
||||||
|
from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
|
||||||
|
|
||||||
|
|
||||||
|
class DummyCommand:
|
||||||
|
id = "hello"
|
||||||
|
description = "Hello command"
|
||||||
|
|
||||||
|
async def handle(self, ctx):
|
||||||
|
_ = ctx
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_entrypoints(monkeypatch):
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"hello",
|
||||||
|
"takopi.commands.hello:BACKEND",
|
||||||
|
plugins.COMMAND_GROUP,
|
||||||
|
loader=DummyCommand,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
return entrypoints
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_registry_lists_ids(command_entrypoints) -> None:
|
||||||
|
ids = commands.list_command_ids()
|
||||||
|
assert "hello" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_registry_gets_command(command_entrypoints) -> None:
|
||||||
|
backend = commands.get_command("hello")
|
||||||
|
assert backend.id == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_registry_unknown(command_entrypoints) -> None:
|
||||||
|
with pytest.raises(ConfigError, match="Unknown command"):
|
||||||
|
commands.get_command("nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_registry_optional_missing(command_entrypoints) -> None:
|
||||||
|
assert commands.get_command("nope", required=False) is None
|
||||||
@@ -1,28 +1,57 @@
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from takopi import cli, engines
|
from takopi import cli, engines, plugins
|
||||||
|
from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
|
||||||
|
|
||||||
|
|
||||||
def test_engine_discovery_skips_non_backend() -> None:
|
@pytest.fixture
|
||||||
|
def engine_entrypoints(monkeypatch):
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"codex",
|
||||||
|
"takopi.runners.codex:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
),
|
||||||
|
FakeEntryPoint(
|
||||||
|
"claude",
|
||||||
|
"takopi.runners.claude:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
),
|
||||||
|
FakeEntryPoint(
|
||||||
|
"bad-id",
|
||||||
|
"takopi.runners.bad:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None))
|
||||||
|
return entrypoints
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_discovery_filters_invalid_ids(engine_entrypoints) -> None:
|
||||||
ids = engines.list_backend_ids()
|
ids = engines.list_backend_ids()
|
||||||
assert "codex" in ids
|
assert ids == ["claude", "codex"]
|
||||||
assert "claude" in ids
|
|
||||||
assert "mock" not in ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_registers_engine_commands_sorted() -> None:
|
def test_cli_registers_engine_commands_sorted(engine_entrypoints) -> None:
|
||||||
command_names = [cmd.name for cmd in cli.app.registered_commands]
|
app = cli.create_app()
|
||||||
|
command_names = [cmd.name for cmd in app.registered_commands]
|
||||||
engine_ids = engines.list_backend_ids()
|
engine_ids = engines.list_backend_ids()
|
||||||
assert set(engine_ids) <= set(command_names)
|
assert set(engine_ids) <= set(command_names)
|
||||||
engine_commands = [name for name in command_names if name in engine_ids]
|
engine_commands = [name for name in command_names if name in engine_ids]
|
||||||
assert engine_commands == engine_ids
|
assert engine_commands == engine_ids
|
||||||
|
|
||||||
|
|
||||||
def test_engine_commands_do_not_expose_engine_id_option() -> None:
|
def test_engine_commands_do_not_expose_engine_id_option(
|
||||||
group = cast(click.Group, typer.main.get_command(cli.app))
|
engine_entrypoints,
|
||||||
|
) -> None:
|
||||||
|
app = cli.create_app()
|
||||||
|
group = cast(click.Group, typer.main.get_command(app))
|
||||||
engine_ids = engines.list_backend_ids()
|
engine_ids = engines.list_backend_ids()
|
||||||
|
|
||||||
ctx = group.make_context("takopi", [])
|
ctx = group.make_context("takopi", [])
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from takopi import plugins
|
||||||
|
from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_ids_does_not_load_entrypoints(monkeypatch) -> None:
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
def loader():
|
||||||
|
calls["count"] += 1
|
||||||
|
return object()
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"codex",
|
||||||
|
"takopi.runners.codex:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
loader=loader,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
ids = plugins.list_ids(plugins.ENGINE_GROUP)
|
||||||
|
assert ids == ["codex"]
|
||||||
|
assert calls["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_entrypoint_records_errors(monkeypatch) -> None:
|
||||||
|
def loader():
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"broken",
|
||||||
|
"takopi.runners.broken:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
loader=loader,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
with pytest.raises(plugins.PluginLoadFailed):
|
||||||
|
plugins.load_entrypoint(plugins.ENGINE_GROUP, "broken")
|
||||||
|
|
||||||
|
errors = plugins.get_load_errors()
|
||||||
|
assert errors
|
||||||
|
assert errors[0].name == "broken"
|
||||||
|
assert "boom" in errors[0].error
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_entrypoints_are_rejected(monkeypatch) -> None:
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"dup",
|
||||||
|
"takopi.runners.one:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
dist_name="one",
|
||||||
|
),
|
||||||
|
FakeEntryPoint(
|
||||||
|
"dup",
|
||||||
|
"takopi.runners.two:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
dist_name="two",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
ids = plugins.list_ids(plugins.ENGINE_GROUP)
|
||||||
|
assert ids == []
|
||||||
|
|
||||||
|
with pytest.raises(plugins.PluginLoadFailed):
|
||||||
|
plugins.load_entrypoint(plugins.ENGINE_GROUP, "dup")
|
||||||
|
|
||||||
|
errors = plugins.get_load_errors()
|
||||||
|
assert any("duplicate plugin id" in err.error for err in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_filters_by_distribution(monkeypatch) -> None:
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"codex",
|
||||||
|
"takopi.runners.codex:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
dist_name="takopi",
|
||||||
|
),
|
||||||
|
FakeEntryPoint(
|
||||||
|
"thirdparty",
|
||||||
|
"takopi_thirdparty.backend:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
dist_name="takopi-thirdparty",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
ids = plugins.list_ids(plugins.ENGINE_GROUP, allowlist=["takopi"])
|
||||||
|
assert ids == ["codex"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validator_errors_are_captured(monkeypatch) -> None:
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"bad",
|
||||||
|
"takopi.runners.bad:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
def validator(obj, ep):
|
||||||
|
raise TypeError("not valid")
|
||||||
|
|
||||||
|
with pytest.raises(plugins.PluginLoadFailed):
|
||||||
|
plugins.load_entrypoint(plugins.ENGINE_GROUP, "bad", validator=validator)
|
||||||
|
|
||||||
|
errors = plugins.get_load_errors()
|
||||||
|
assert any("not valid" in err.error for err in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_plugin_state_clears_cache(monkeypatch) -> None:
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
def loader():
|
||||||
|
calls["count"] += 1
|
||||||
|
return object()
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"codex",
|
||||||
|
"takopi.runners.codex:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
loader=loader,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
plugins.load_entrypoint(plugins.ENGINE_GROUP, "codex")
|
||||||
|
plugins.load_entrypoint(plugins.ENGINE_GROUP, "codex")
|
||||||
|
assert calls["count"] == 1
|
||||||
|
|
||||||
|
plugins.reset_plugin_state()
|
||||||
|
plugins.load_entrypoint(plugins.ENGINE_GROUP, "codex")
|
||||||
|
assert calls["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_load_errors_filters(monkeypatch) -> None:
|
||||||
|
def loader():
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"broken_engine",
|
||||||
|
"takopi.runners.broken:BACKEND",
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
loader=loader,
|
||||||
|
dist_name="engine-dist",
|
||||||
|
),
|
||||||
|
FakeEntryPoint(
|
||||||
|
"broken_transport",
|
||||||
|
"takopi.transports.broken:BACKEND",
|
||||||
|
plugins.TRANSPORT_GROUP,
|
||||||
|
loader=loader,
|
||||||
|
dist_name="transport-dist",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
with pytest.raises(plugins.PluginLoadFailed):
|
||||||
|
plugins.load_entrypoint(plugins.ENGINE_GROUP, "broken_engine")
|
||||||
|
with pytest.raises(plugins.PluginLoadFailed):
|
||||||
|
plugins.load_entrypoint(plugins.TRANSPORT_GROUP, "broken_transport")
|
||||||
|
|
||||||
|
errors = plugins.get_load_errors()
|
||||||
|
assert {err.group for err in errors} == {
|
||||||
|
plugins.ENGINE_GROUP,
|
||||||
|
plugins.TRANSPORT_GROUP,
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins.clear_load_errors(group=plugins.ENGINE_GROUP)
|
||||||
|
errors = plugins.get_load_errors()
|
||||||
|
assert {err.group for err in errors} == {plugins.TRANSPORT_GROUP}
|
||||||
|
|
||||||
|
plugins.clear_load_errors(name="broken_transport")
|
||||||
|
assert plugins.get_load_errors() == ()
|
||||||
@@ -35,13 +35,14 @@ def test_init_writes_project(monkeypatch, tmp_path) -> None:
|
|||||||
config_path = tmp_path / "takopi.toml"
|
config_path = tmp_path / "takopi.toml"
|
||||||
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
|
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
|
||||||
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
|
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
|
||||||
|
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None))
|
||||||
|
|
||||||
repo_path = tmp_path / "repo"
|
repo_path = tmp_path / "repo"
|
||||||
repo_path.mkdir()
|
repo_path.mkdir()
|
||||||
monkeypatch.chdir(repo_path)
|
monkeypatch.chdir(repo_path)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli.app, ["init", "z80"])
|
result = runner.invoke(cli.create_app(), ["init", "z80"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
saved = config_path.read_text(encoding="utf-8")
|
saved = config_path.read_text(encoding="utf-8")
|
||||||
@@ -56,13 +57,14 @@ def test_init_migrates_legacy_config(monkeypatch, tmp_path) -> None:
|
|||||||
config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
|
config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
|
||||||
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
|
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
|
||||||
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
|
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
|
||||||
|
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None))
|
||||||
|
|
||||||
repo_path = tmp_path / "repo"
|
repo_path = tmp_path / "repo"
|
||||||
repo_path.mkdir()
|
repo_path.mkdir()
|
||||||
monkeypatch.chdir(repo_path)
|
monkeypatch.chdir(repo_path)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli.app, ["init", "z80"])
|
result = runner.invoke(cli.create_app(), ["init", "z80"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
raw = read_raw_toml(config_path)
|
raw = read_raw_toml(config_path)
|
||||||
|
|||||||
+313
-47
@@ -3,15 +3,16 @@ from pathlib import Path
|
|||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from takopi import commands, plugins
|
||||||
|
import takopi.telegram.bridge as bridge
|
||||||
|
from takopi.directives import parse_directives
|
||||||
from takopi.telegram.bridge import (
|
from takopi.telegram.bridge import (
|
||||||
TelegramBridgeConfig,
|
TelegramBridgeConfig,
|
||||||
TelegramTransport,
|
TelegramTransport,
|
||||||
_build_bot_commands,
|
_build_bot_commands,
|
||||||
_handle_cancel,
|
_handle_cancel,
|
||||||
_is_cancel_command,
|
_is_cancel_command,
|
||||||
_resolve_message,
|
|
||||||
_send_with_resume,
|
_send_with_resume,
|
||||||
_strip_engine_command,
|
|
||||||
run_main_loop,
|
run_main_loop,
|
||||||
)
|
)
|
||||||
from takopi.context import RunContext
|
from takopi.context import RunContext
|
||||||
@@ -20,8 +21,11 @@ from takopi.runner_bridge import ExecBridgeConfig, RunningTask
|
|||||||
from takopi.markdown import MarkdownPresenter
|
from takopi.markdown import MarkdownPresenter
|
||||||
from takopi.model import EngineId, ResumeToken
|
from takopi.model import EngineId, ResumeToken
|
||||||
from takopi.router import AutoRouter, RunnerEntry
|
from takopi.router import AutoRouter, RunnerEntry
|
||||||
|
from takopi.transport_runtime import TransportRuntime
|
||||||
from takopi.runners.mock import Return, ScriptRunner, Sleep, Wait
|
from takopi.runners.mock import Return, ScriptRunner, Sleep, Wait
|
||||||
from takopi.transport import IncomingMessage, MessageRef, RenderedMessage, SendOptions
|
from takopi.telegram.types import TelegramIncomingMessage
|
||||||
|
from takopi.transport import MessageRef, RenderedMessage, SendOptions
|
||||||
|
from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
|
||||||
|
|
||||||
CODEX_ENGINE = EngineId("codex")
|
CODEX_ENGINE = EngineId("codex")
|
||||||
|
|
||||||
@@ -185,59 +189,78 @@ def _make_cfg(
|
|||||||
presenter=MarkdownPresenter(),
|
presenter=MarkdownPresenter(),
|
||||||
final_notify=True,
|
final_notify=True,
|
||||||
)
|
)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=_make_router(runner),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
)
|
||||||
return TelegramBridgeConfig(
|
return TelegramBridgeConfig(
|
||||||
bot=_FakeBot(),
|
bot=_FakeBot(),
|
||||||
router=_make_router(runner),
|
runtime=runtime,
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
startup_msg="",
|
startup_msg="",
|
||||||
exec_cfg=exec_cfg,
|
exec_cfg=exec_cfg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_strip_engine_command_inline() -> None:
|
def test_parse_directives_inline_engine() -> None:
|
||||||
text, engine = _strip_engine_command(
|
directives = parse_directives(
|
||||||
"/claude do it", engine_ids=("codex", "claude")
|
"/claude do it",
|
||||||
|
engine_ids=("codex", "claude"),
|
||||||
|
projects=empty_projects_config(),
|
||||||
)
|
)
|
||||||
assert engine == "claude"
|
assert directives.engine == "claude"
|
||||||
assert text == "do it"
|
assert directives.prompt == "do it"
|
||||||
|
|
||||||
|
|
||||||
def test_strip_engine_command_newline() -> None:
|
def test_parse_directives_newline() -> None:
|
||||||
text, engine = _strip_engine_command(
|
directives = parse_directives(
|
||||||
"/codex\nhello", engine_ids=("codex", "claude")
|
"/codex\nhello",
|
||||||
|
engine_ids=("codex", "claude"),
|
||||||
|
projects=empty_projects_config(),
|
||||||
)
|
)
|
||||||
assert engine == "codex"
|
assert directives.engine == "codex"
|
||||||
assert text == "hello"
|
assert directives.prompt == "hello"
|
||||||
|
|
||||||
|
|
||||||
def test_strip_engine_command_ignores_unknown() -> None:
|
def test_parse_directives_ignores_unknown() -> None:
|
||||||
text, engine = _strip_engine_command("/unknown hi", engine_ids=("codex", "claude"))
|
directives = parse_directives(
|
||||||
assert engine is None
|
"/unknown hi",
|
||||||
assert text == "/unknown hi"
|
engine_ids=("codex", "claude"),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
|
||||||
def test_strip_engine_command_bot_suffix() -> None:
|
|
||||||
text, engine = _strip_engine_command(
|
|
||||||
"/claude@bunny_agent_bot hi", engine_ids=("claude",)
|
|
||||||
)
|
)
|
||||||
assert engine == "claude"
|
assert directives.engine is None
|
||||||
assert text == "hi"
|
assert directives.prompt == "/unknown hi"
|
||||||
|
|
||||||
|
|
||||||
def test_strip_engine_command_only_first_non_empty_line() -> None:
|
def test_parse_directives_bot_suffix() -> None:
|
||||||
text, engine = _strip_engine_command(
|
directives = parse_directives(
|
||||||
"hello\n/claude hi", engine_ids=("codex", "claude")
|
"/claude@bunny_agent_bot hi",
|
||||||
|
engine_ids=("claude",),
|
||||||
|
projects=empty_projects_config(),
|
||||||
)
|
)
|
||||||
assert engine is None
|
assert directives.engine == "claude"
|
||||||
assert text == "hello\n/claude hi"
|
assert directives.prompt == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_directives_only_first_non_empty_line() -> None:
|
||||||
|
directives = parse_directives(
|
||||||
|
"hello\n/claude hi",
|
||||||
|
engine_ids=("codex", "claude"),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
)
|
||||||
|
assert directives.engine is None
|
||||||
|
assert directives.prompt == "hello\n/claude hi"
|
||||||
|
|
||||||
|
|
||||||
def test_build_bot_commands_includes_cancel_and_engine() -> None:
|
def test_build_bot_commands_includes_cancel_and_engine() -> None:
|
||||||
runner = ScriptRunner(
|
runner = ScriptRunner(
|
||||||
[Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid"
|
[Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid"
|
||||||
)
|
)
|
||||||
router = _make_router(runner)
|
runtime = TransportRuntime(
|
||||||
commands = _build_bot_commands(router, empty_projects_config())
|
router=_make_router(runner),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
)
|
||||||
|
commands = _build_bot_commands(runtime)
|
||||||
|
|
||||||
assert {"command": "cancel", "description": "cancel run"} in commands
|
assert {"command": "cancel", "description": "cancel run"} in commands
|
||||||
assert any(cmd["command"] == "codex" for cmd in commands)
|
assert any(cmd["command"] == "codex" for cmd in commands)
|
||||||
@@ -264,12 +287,42 @@ def test_build_bot_commands_includes_projects() -> None:
|
|||||||
default_project=None,
|
default_project=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
commands = _build_bot_commands(router, projects)
|
runtime = TransportRuntime(router=router, projects=projects)
|
||||||
|
commands = _build_bot_commands(runtime)
|
||||||
|
|
||||||
assert any(cmd["command"] == "good" for cmd in commands)
|
assert any(cmd["command"] == "good" for cmd in commands)
|
||||||
assert not any(cmd["command"] == "bad-name" for cmd in commands)
|
assert not any(cmd["command"] == "bad-name" for cmd in commands)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None:
|
||||||
|
class _Command:
|
||||||
|
id = "pingcmd"
|
||||||
|
description = "ping command"
|
||||||
|
|
||||||
|
async def handle(self, ctx):
|
||||||
|
_ = ctx
|
||||||
|
return None
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"pingcmd",
|
||||||
|
"takopi.commands.ping:BACKEND",
|
||||||
|
plugins.COMMAND_GROUP,
|
||||||
|
loader=_Command,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=_make_router(runner),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
commands_list = _build_bot_commands(runtime)
|
||||||
|
|
||||||
|
assert {"command": "pingcmd", "description": "ping command"} in commands_list
|
||||||
|
|
||||||
|
|
||||||
def test_build_bot_commands_caps_total() -> None:
|
def test_build_bot_commands_caps_total() -> None:
|
||||||
runner = ScriptRunner(
|
runner = ScriptRunner(
|
||||||
[Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid"
|
[Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid"
|
||||||
@@ -287,7 +340,8 @@ def test_build_bot_commands_caps_total() -> None:
|
|||||||
default_project=None,
|
default_project=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
commands = _build_bot_commands(router, projects)
|
runtime = TransportRuntime(router=router, projects=projects)
|
||||||
|
commands = _build_bot_commands(runtime)
|
||||||
|
|
||||||
assert len(commands) == 100
|
assert len(commands) == 100
|
||||||
assert any(cmd["command"] == "codex" for cmd in commands)
|
assert any(cmd["command"] == "codex" for cmd in commands)
|
||||||
@@ -410,7 +464,7 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
|||||||
async def test_handle_cancel_without_reply_prompts_user() -> None:
|
async def test_handle_cancel_without_reply_prompts_user() -> None:
|
||||||
transport = _FakeTransport()
|
transport = _FakeTransport()
|
||||||
cfg = _make_cfg(transport)
|
cfg = _make_cfg(transport)
|
||||||
msg = IncomingMessage(
|
msg = TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
message_id=10,
|
message_id=10,
|
||||||
@@ -431,7 +485,7 @@ async def test_handle_cancel_without_reply_prompts_user() -> None:
|
|||||||
async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> None:
|
async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> None:
|
||||||
transport = _FakeTransport()
|
transport = _FakeTransport()
|
||||||
cfg = _make_cfg(transport)
|
cfg = _make_cfg(transport)
|
||||||
msg = IncomingMessage(
|
msg = TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
message_id=10,
|
message_id=10,
|
||||||
@@ -453,7 +507,7 @@ async def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
|
|||||||
transport = _FakeTransport()
|
transport = _FakeTransport()
|
||||||
cfg = _make_cfg(transport)
|
cfg = _make_cfg(transport)
|
||||||
progress_id = 99
|
progress_id = 99
|
||||||
msg = IncomingMessage(
|
msg = TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
message_id=10,
|
message_id=10,
|
||||||
@@ -475,7 +529,7 @@ async def test_handle_cancel_cancels_running_task() -> None:
|
|||||||
transport = _FakeTransport()
|
transport = _FakeTransport()
|
||||||
cfg = _make_cfg(transport)
|
cfg = _make_cfg(transport)
|
||||||
progress_id = 42
|
progress_id = 42
|
||||||
msg = IncomingMessage(
|
msg = TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
message_id=10,
|
message_id=10,
|
||||||
@@ -499,7 +553,7 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
|
|||||||
cfg = _make_cfg(transport)
|
cfg = _make_cfg(transport)
|
||||||
task_first = RunningTask()
|
task_first = RunningTask()
|
||||||
task_second = RunningTask()
|
task_second = RunningTask()
|
||||||
msg = IncomingMessage(
|
msg = TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
message_id=10,
|
message_id=10,
|
||||||
@@ -527,7 +581,8 @@ def test_cancel_command_accepts_extra_text() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_resolve_message_accepts_backticked_ctx_line() -> None:
|
def test_resolve_message_accepts_backticked_ctx_line() -> None:
|
||||||
router = _make_router(ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE))
|
runtime = TransportRuntime(
|
||||||
|
router=_make_router(ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)),
|
||||||
projects=ProjectsConfig(
|
projects=ProjectsConfig(
|
||||||
projects={
|
projects={
|
||||||
"takopi": ProjectConfig(
|
"takopi": ProjectConfig(
|
||||||
@@ -537,13 +592,11 @@ def test_resolve_message_accepts_backticked_ctx_line() -> None:
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
default_project=None,
|
default_project=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
resolved = runtime.resolve_message(
|
||||||
resolved = _resolve_message(
|
|
||||||
text="do it",
|
text="do it",
|
||||||
reply_text="`ctx: takopi @ feat/api`",
|
reply_text="`ctx: takopi @ feat/api`",
|
||||||
router=router,
|
|
||||||
projects=projects,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resolved.prompt == "do it"
|
assert resolved.prompt == "do it"
|
||||||
@@ -643,16 +696,20 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
|
|||||||
presenter=MarkdownPresenter(),
|
presenter=MarkdownPresenter(),
|
||||||
final_notify=True,
|
final_notify=True,
|
||||||
)
|
)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=_make_router(runner),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
)
|
||||||
cfg = TelegramBridgeConfig(
|
cfg = TelegramBridgeConfig(
|
||||||
bot=bot,
|
bot=bot,
|
||||||
router=_make_router(runner),
|
runtime=runtime,
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
startup_msg="",
|
startup_msg="",
|
||||||
exec_cfg=exec_cfg,
|
exec_cfg=exec_cfg,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def poller(_cfg: TelegramBridgeConfig):
|
async def poller(_cfg: TelegramBridgeConfig):
|
||||||
yield IncomingMessage(
|
yield TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
message_id=1,
|
message_id=1,
|
||||||
@@ -666,7 +723,7 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
|
|||||||
assert isinstance(transport.progress_ref.message_id, int)
|
assert isinstance(transport.progress_ref.message_id, int)
|
||||||
reply_id = transport.progress_ref.message_id
|
reply_id = transport.progress_ref.message_id
|
||||||
reply_ready.set()
|
reply_ready.set()
|
||||||
yield IncomingMessage(
|
yield TelegramIncomingMessage(
|
||||||
transport="telegram",
|
transport="telegram",
|
||||||
chat_id=123,
|
chat_id=123,
|
||||||
message_id=2,
|
message_id=2,
|
||||||
@@ -694,3 +751,212 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
|
|||||||
hold.set()
|
hold.set()
|
||||||
stop_polling.set()
|
stop_polling.set()
|
||||||
tg.cancel_scope.cancel()
|
tg.cancel_scope.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None:
|
||||||
|
class _Command:
|
||||||
|
id = "echo_cmd"
|
||||||
|
description = "echo"
|
||||||
|
|
||||||
|
async def handle(self, ctx):
|
||||||
|
return commands.CommandResult(text=f"echo:{ctx.args_text}")
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"echo_cmd",
|
||||||
|
"takopi.commands.echo:BACKEND",
|
||||||
|
plugins.COMMAND_GROUP,
|
||||||
|
loader=_Command,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
transport = _FakeTransport()
|
||||||
|
bot = _FakeBot()
|
||||||
|
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
|
||||||
|
exec_cfg = ExecBridgeConfig(
|
||||||
|
transport=transport,
|
||||||
|
presenter=MarkdownPresenter(),
|
||||||
|
final_notify=True,
|
||||||
|
)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=_make_router(runner),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
)
|
||||||
|
cfg = TelegramBridgeConfig(
|
||||||
|
bot=bot,
|
||||||
|
runtime=runtime,
|
||||||
|
chat_id=123,
|
||||||
|
startup_msg="",
|
||||||
|
exec_cfg=exec_cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poller(_cfg: TelegramBridgeConfig):
|
||||||
|
yield TelegramIncomingMessage(
|
||||||
|
transport="telegram",
|
||||||
|
chat_id=123,
|
||||||
|
message_id=1,
|
||||||
|
text="/echo_cmd hello",
|
||||||
|
reply_to_message_id=None,
|
||||||
|
reply_to_text=None,
|
||||||
|
sender_id=123,
|
||||||
|
)
|
||||||
|
|
||||||
|
await run_main_loop(cfg, poller)
|
||||||
|
|
||||||
|
assert runner.calls == []
|
||||||
|
assert transport.send_calls
|
||||||
|
assert transport.send_calls[-1]["message"].text == "echo:hello"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_main_loop_command_uses_project_default_engine(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
class _Command:
|
||||||
|
id = "use_project"
|
||||||
|
description = "use project default"
|
||||||
|
|
||||||
|
async def handle(self, ctx):
|
||||||
|
result = await ctx.executor.run_one(
|
||||||
|
commands.RunRequest(
|
||||||
|
prompt="hello",
|
||||||
|
context=RunContext(project="proj"),
|
||||||
|
),
|
||||||
|
mode="capture",
|
||||||
|
)
|
||||||
|
return commands.CommandResult(text=f"ran:{result.engine}")
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"use_project",
|
||||||
|
"takopi.commands.use_project:BACKEND",
|
||||||
|
plugins.COMMAND_GROUP,
|
||||||
|
loader=_Command,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
transport = _FakeTransport()
|
||||||
|
bot = _FakeBot()
|
||||||
|
codex_runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
|
||||||
|
pi_runner = ScriptRunner([Return(answer="ok")], engine=EngineId("pi"))
|
||||||
|
router = AutoRouter(
|
||||||
|
entries=[
|
||||||
|
RunnerEntry(engine=codex_runner.engine, runner=codex_runner),
|
||||||
|
RunnerEntry(engine=pi_runner.engine, runner=pi_runner),
|
||||||
|
],
|
||||||
|
default_engine=codex_runner.engine,
|
||||||
|
)
|
||||||
|
projects = ProjectsConfig(
|
||||||
|
projects={
|
||||||
|
"proj": ProjectConfig(
|
||||||
|
alias="proj",
|
||||||
|
path=Path("."),
|
||||||
|
worktrees_dir=Path(".worktrees"),
|
||||||
|
default_engine=pi_runner.engine,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
default_project=None,
|
||||||
|
)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=router,
|
||||||
|
projects=projects,
|
||||||
|
)
|
||||||
|
exec_cfg = ExecBridgeConfig(
|
||||||
|
transport=transport,
|
||||||
|
presenter=MarkdownPresenter(),
|
||||||
|
final_notify=True,
|
||||||
|
)
|
||||||
|
cfg = TelegramBridgeConfig(
|
||||||
|
bot=bot,
|
||||||
|
runtime=runtime,
|
||||||
|
chat_id=123,
|
||||||
|
startup_msg="",
|
||||||
|
exec_cfg=exec_cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poller(_cfg: TelegramBridgeConfig):
|
||||||
|
yield TelegramIncomingMessage(
|
||||||
|
transport="telegram",
|
||||||
|
chat_id=123,
|
||||||
|
message_id=1,
|
||||||
|
text="/use_project",
|
||||||
|
reply_to_message_id=None,
|
||||||
|
reply_to_text=None,
|
||||||
|
sender_id=123,
|
||||||
|
)
|
||||||
|
|
||||||
|
await run_main_loop(cfg, poller)
|
||||||
|
|
||||||
|
assert codex_runner.calls == []
|
||||||
|
assert len(pi_runner.calls) == 1
|
||||||
|
assert transport.send_calls[-1]["message"].text == "ran:pi"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
|
||||||
|
class _Command:
|
||||||
|
id = "late_cmd"
|
||||||
|
description = "late command"
|
||||||
|
|
||||||
|
async def handle(self, ctx):
|
||||||
|
return commands.CommandResult(text="late")
|
||||||
|
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"late_cmd",
|
||||||
|
"takopi.commands.late:BACKEND",
|
||||||
|
plugins.COMMAND_GROUP,
|
||||||
|
loader=_Command,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
def _list_command_ids(*, allowlist=None):
|
||||||
|
_ = allowlist
|
||||||
|
calls["count"] += 1
|
||||||
|
if calls["count"] == 1:
|
||||||
|
return []
|
||||||
|
return ["late_cmd"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(bridge, "list_command_ids", _list_command_ids)
|
||||||
|
|
||||||
|
transport = _FakeTransport()
|
||||||
|
bot = _FakeBot()
|
||||||
|
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
|
||||||
|
exec_cfg = ExecBridgeConfig(
|
||||||
|
transport=transport,
|
||||||
|
presenter=MarkdownPresenter(),
|
||||||
|
final_notify=True,
|
||||||
|
)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=_make_router(runner),
|
||||||
|
projects=empty_projects_config(),
|
||||||
|
)
|
||||||
|
cfg = TelegramBridgeConfig(
|
||||||
|
bot=bot,
|
||||||
|
runtime=runtime,
|
||||||
|
chat_id=123,
|
||||||
|
startup_msg="",
|
||||||
|
exec_cfg=exec_cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poller(_cfg: TelegramBridgeConfig):
|
||||||
|
yield TelegramIncomingMessage(
|
||||||
|
transport="telegram",
|
||||||
|
chat_id=123,
|
||||||
|
message_id=1,
|
||||||
|
text="/late_cmd hello",
|
||||||
|
reply_to_message_id=None,
|
||||||
|
reply_to_text=None,
|
||||||
|
sender_id=123,
|
||||||
|
)
|
||||||
|
|
||||||
|
await run_main_loop(cfg, poller)
|
||||||
|
|
||||||
|
assert calls["count"] >= 2
|
||||||
|
assert transport.send_calls[-1]["message"].text == "late"
|
||||||
|
|||||||
@@ -1,19 +1,67 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from takopi import transports
|
from takopi import plugins, transports
|
||||||
from takopi.config import ConfigError
|
from takopi.config import ConfigError
|
||||||
|
from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
|
||||||
|
|
||||||
|
|
||||||
def test_transport_registry_lists_telegram() -> None:
|
class DummyTransport:
|
||||||
|
id = "telegram"
|
||||||
|
description = "Telegram"
|
||||||
|
|
||||||
|
def check_setup(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def interactive_setup(self, *, force: bool) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def lock_token(self, *, transport_config: dict[str, object], config_path):
|
||||||
|
_ = transport_config, config_path
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def build_and_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
transport_config: dict[str, object],
|
||||||
|
config_path,
|
||||||
|
runtime,
|
||||||
|
final_notify: bool,
|
||||||
|
default_engine_override: str | None,
|
||||||
|
) -> None:
|
||||||
|
_ = (
|
||||||
|
transport_config,
|
||||||
|
config_path,
|
||||||
|
runtime,
|
||||||
|
final_notify,
|
||||||
|
default_engine_override,
|
||||||
|
)
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def transport_entrypoints(monkeypatch):
|
||||||
|
entrypoints = [
|
||||||
|
FakeEntryPoint(
|
||||||
|
"telegram",
|
||||||
|
"takopi.telegram.backend:telegram_backend",
|
||||||
|
plugins.TRANSPORT_GROUP,
|
||||||
|
loader=DummyTransport,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
install_entrypoints(monkeypatch, entrypoints)
|
||||||
|
return entrypoints
|
||||||
|
|
||||||
|
|
||||||
|
def test_transport_registry_lists_telegram(transport_entrypoints) -> None:
|
||||||
ids = transports.list_transports()
|
ids = transports.list_transports()
|
||||||
assert "telegram" in ids
|
assert "telegram" in ids
|
||||||
|
|
||||||
|
|
||||||
def test_transport_registry_gets_telegram() -> None:
|
def test_transport_registry_gets_telegram(transport_entrypoints) -> None:
|
||||||
backend = transports.get_transport("telegram")
|
backend = transports.get_transport("telegram")
|
||||||
assert backend.id == "telegram"
|
assert backend.id == "telegram"
|
||||||
|
|
||||||
|
|
||||||
def test_transport_registry_unknown() -> None:
|
def test_transport_registry_unknown(transport_entrypoints) -> None:
|
||||||
with pytest.raises(ConfigError, match="Unknown transport"):
|
with pytest.raises(ConfigError, match="Unknown transport"):
|
||||||
transports.get_transport("nope")
|
transports.get_transport("nope")
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from takopi.config import ProjectConfig, ProjectsConfig
|
||||||
|
from takopi.context import RunContext
|
||||||
|
from takopi.router import AutoRouter, RunnerEntry
|
||||||
|
from takopi.runners.mock import Return, ScriptRunner
|
||||||
|
from takopi.transport_runtime import TransportRuntime
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runtime(*, project_default_engine: str | None = None) -> TransportRuntime:
|
||||||
|
codex = ScriptRunner([Return(answer="ok")], engine="codex")
|
||||||
|
pi = ScriptRunner([Return(answer="ok")], engine="pi")
|
||||||
|
router = AutoRouter(
|
||||||
|
entries=[
|
||||||
|
RunnerEntry(engine=codex.engine, runner=codex),
|
||||||
|
RunnerEntry(engine=pi.engine, runner=pi),
|
||||||
|
],
|
||||||
|
default_engine=codex.engine,
|
||||||
|
)
|
||||||
|
project = ProjectConfig(
|
||||||
|
alias="proj",
|
||||||
|
path=Path("."),
|
||||||
|
worktrees_dir=Path(".worktrees"),
|
||||||
|
default_engine=project_default_engine,
|
||||||
|
)
|
||||||
|
projects = ProjectsConfig(projects={"proj": project}, default_project=None)
|
||||||
|
return TransportRuntime(router=router, projects=projects)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_engine_uses_project_default() -> None:
|
||||||
|
runtime = _make_runtime(project_default_engine="pi")
|
||||||
|
engine = runtime.resolve_engine(
|
||||||
|
engine_override=None,
|
||||||
|
context=RunContext(project="proj"),
|
||||||
|
)
|
||||||
|
assert engine == "pi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_engine_prefers_override() -> None:
|
||||||
|
runtime = _make_runtime(project_default_engine="pi")
|
||||||
|
engine = runtime.resolve_engine(
|
||||||
|
engine_override="codex",
|
||||||
|
context=RunContext(project="proj"),
|
||||||
|
)
|
||||||
|
assert engine == "codex"
|
||||||
Reference in New Issue
Block a user