Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/explanation/module-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ This page is a high-level map of Takopi’s internal modules: what they do and h
| `telegram/client.py` | Telegram API wrapper with retry/outbox semantics. |
| `telegram/render.py` | Telegram markdown rendering and trimming. |
| `telegram/onboarding.py` | Interactive setup and setup validation UX. |
| `telegram/commands/*` | In-chat command handlers (`/agent`, `/file`, `/topic`, `/ctx`, `/new`, …). |
| `telegram/commands/*` | In-chat command handlers (`/agent`, `/mode`, `/file`, `/topic`, `/ctx`, `/new`, …). |

## Plugins

Expand Down Expand Up @@ -78,4 +78,3 @@ This page is a high-level map of Takopi’s internal modules: what they do and h
| `utils/paths.py` | Path/command relativization helpers. |
| `utils/streams.py` | Async stream helpers (`iter_bytes_lines`, stderr draining). |
| `utils/subprocess.py` | Subprocess management helpers (terminate/kill best-effort). |

16 changes: 16 additions & 0 deletions docs/how-to/write-a-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ BACKEND = EngineBackend(
)
```

Optional: if your engine supports agent modes (for example `--agent`), expose them
to Telegram `/mode` by adding `discover_agent_modes`:

```py
from takopi.api import AgentModeCapabilities


def discover_agent_modes(timeout_s: float) -> AgentModeCapabilities:
_ = timeout_s
return AgentModeCapabilities(
supports_agent=True,
known_modes=("plan", "build"),
shortcut_modes=("plan", "build"),
)
```

Engine config is a raw table in `takopi.toml`:

=== "takopi config"
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/commands-and-directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This line is parsed from replies and takes precedence over new directives.
|---------|-------------|
| `/cancel` | Reply to the progress message to stop the current run. |
| `/agent` | Show/set the default engine for the current scope. |
| `/mode` | Show/set the agent mode override for the current scope. |
| `/model` | Show/set the model override for the current scope. |
| `/reasoning` | Show/set the reasoning override for the current scope. |
| `/trigger` | Show/set trigger mode (mentions-only vs all). |
Expand All @@ -52,6 +53,8 @@ Notes:
- Outside topics, `/ctx` binds the chat context.
- In topics, `/ctx` binds the topic context.
- `/new` clears sessions but does **not** clear a bound context.
- Dynamic `/<mode>` shortcuts (for example `/plan`, `/build`) are available
when the selected engine reports known modes at startup.

## CLI

Expand Down
1 change: 1 addition & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ If you expect to edit config while Takopi is running, set:
| `allowed_user_ids` | int[] | `[]` | Allowed sender user ids. Empty disables sender filtering; when set, only these users can interact (including DMs). |
| `message_overflow` | `"trim"`\|`"split"` | `"trim"` | How to handle long final responses. |
| `forward_coalesce_s` | float | `1.0` | Quiet window for combining a prompt with immediately-following forwarded messages; set `0` to disable. |
| `mode_discovery_timeout_s` | float | `8.0` | Timeout (seconds) for agent-mode discovery commands (for example `opencode agent list`) at startup. |
| `voice_transcription` | bool | `false` | Enable voice note transcription. |
| `voice_max_bytes` | int | `10485760` | Max voice note size (bytes). |
| `voice_transcription_model` | string | `"gpt-4o-mini-transcribe"` | OpenAI transcription model name. |
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies = ["takopi>=0.14,<0.15"]
| Symbol | Purpose |
|--------|---------|
| `EngineBackend` | Declares an engine backend (id + runner builder) |
| `AgentModeCapabilities` | Optional engine capability payload for `/mode` support |
| `EngineConfig` | Dict-based engine config table |
| `Runner` | Runner protocol |
| `BaseRunner` | Helper base class with resume locking |
Expand Down Expand Up @@ -143,13 +144,16 @@ EngineBackend(
build_runner: Callable[[EngineConfig, Path], Runner],
cli_cmd: str | None = None,
install_cmd: str | None = None,
discover_agent_modes: Callable[[float], AgentModeCapabilities] | 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.
- `discover_agent_modes` is optional; when provided it should return
`AgentModeCapabilities` for that engine (for Telegram `/mode` support).

---

Expand Down
3 changes: 3 additions & 0 deletions docs/reference/runners/opencode/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ Add to your `takopi.toml`:
takopi opencode
```

When using Telegram, `/mode <name>` stores an OpenCode agent mode override for
the current scope and Takopi runs OpenCode with `--agent <name>`.

## Resume Format

Resume line format: `` `opencode --session ses_XXX` ``
Expand Down
26 changes: 25 additions & 1 deletion docs/reference/transports/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Explicit invocation includes any of:
- `@botname` mention in the message.
- `/<engine-id>` or `/<project-alias>` as the first token.
- Replying to a bot message.
- Built-in or plugin slash commands (for example `/agent`, `/model`, `/reasoning`, `/file`, `/trigger`).
- Built-in or plugin slash commands (for example `/agent`, `/mode`, `/model`, `/reasoning`, `/file`, `/trigger`).

Note: In forum topics, some Telegram clients include `reply_to_message` on every
message, pointing at the topic’s root service message (`message_id ==
Expand All @@ -93,6 +93,30 @@ In group chats, changing trigger mode requires the sender to be an admin.
State is stored in `telegram_chat_prefs_state.json` (chat default) and
`telegram_topics_state.json` (topic overrides) alongside the config file.

### Agent mode discovery timeout

At startup, Takopi discovers available agent modes (for example via
`opencode agent list`) so dynamic shortcuts like `/build` and `/plan`
can be registered.

Shortcuts are best-effort: if discovery times out or fails, Takopi still starts,
`/mode` remains available, and shortcut commands are omitted until a successful
discovery cycle.

If your host is slow on cold start, increase the timeout:

=== "takopi config"

```sh
takopi config set transports.telegram.mode_discovery_timeout_s 8.0
```

=== "toml"

```toml
mode_discovery_timeout_s = 8.0
```

### Forwarded message coalescing

Telegram sends a "comment + forwards" burst as separate messages, with the comment
Expand Down
50 changes: 50 additions & 0 deletions src/takopi/agent_modes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

from dataclasses import dataclass, field
import subprocess

from .logging import get_logger

logger = get_logger(__name__)


@dataclass(frozen=True, slots=True)
class AgentModeCapabilities:
supports_agent: bool = False
known_modes: tuple[str, ...] = ()
shortcut_modes: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class ModeDiscoveryResult:
supports_agent: frozenset[str] = field(default_factory=frozenset)
known_modes: dict[str, tuple[str, ...]] = field(default_factory=dict)
shortcut_modes: tuple[str, ...] = ()


def probe_agent_support_via_help(cmd: str, timeout_s: float) -> AgentModeCapabilities:
try:
proc = subprocess.run(
[cmd, "--help"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_s,
check=False,
)
except OSError as exc:
logger.info(
"agent_modes.help.failed",
cmd=cmd,
error=str(exc),
error_type=exc.__class__.__name__,
)
return AgentModeCapabilities()
except subprocess.TimeoutExpired:
logger.info("agent_modes.help.timeout", cmd=cmd, timeout_s=timeout_s)
return AgentModeCapabilities()
output = f"{proc.stdout}\n{proc.stderr}".strip()
if "--agent" not in output:
return AgentModeCapabilities()
return AgentModeCapabilities(supports_agent=True)
2 changes: 2 additions & 0 deletions src/takopi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from .backends import EngineBackend, EngineConfig, SetupIssue
from .agent_modes import AgentModeCapabilities
from .commands import (
CommandBackend,
CommandContext,
Expand Down Expand Up @@ -55,6 +56,7 @@
# Core types
"Action",
"ActionEvent",
"AgentModeCapabilities",
"BaseRunner",
"CompletedEvent",
"ConfigError",
Expand Down
4 changes: 4 additions & 0 deletions src/takopi/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .agent_modes import AgentModeCapabilities

if TYPE_CHECKING:
from .runner import Runner

EngineConfig = dict[str, Any]
AgentModeProbe = Callable[[float], AgentModeCapabilities]


@dataclass(frozen=True, slots=True)
Expand All @@ -23,3 +26,4 @@ class EngineBackend:
build_runner: Callable[[EngineConfig, Path], Runner]
cli_cmd: str | None = None
install_cmd: str | None = None
discover_agent_modes: AgentModeProbe | None = None
13 changes: 12 additions & 1 deletion src/takopi/ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,18 @@

RESERVED_CLI_COMMANDS = frozenset({"config", "doctor", "init", "plugins"})
RESERVED_CHAT_COMMANDS = frozenset(
{"cancel", "file", "new", "agent", "model", "reasoning", "trigger", "topic", "ctx"}
{
"cancel",
"file",
"new",
"agent",
"mode",
"model",
"reasoning",
"trigger",
"topic",
"ctx",
}
)
RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
Expand Down
2 changes: 2 additions & 0 deletions src/takopi/runners/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ def _build_args(self, prompt: str, resume: ResumeToken | None) -> list[str]:
model = run_options.model
if model is not None:
args.extend(["--model", str(model)])
if run_options is not None and run_options.mode:
args.extend(["--agent", str(run_options.mode)])
allowed_tools = _coerce_comma_list(self.allowed_tools)
if allowed_tools is not None:
args.extend(["--allowedTools", allowed_tools])
Expand Down
4 changes: 3 additions & 1 deletion src/takopi/runners/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
try:
attempt = int(match.group("attempt"))
max_attempts = int(match.group("max"))
except (TypeError, ValueError):
except TypeError, ValueError:
return None
return (attempt, max_attempts)

Expand Down Expand Up @@ -483,6 +483,8 @@ def build_args(
f"model_reasoning_effort={run_options.reasoning}",
]
)
if run_options.mode:
args.extend(["--agent", str(run_options.mode)])
args.extend(
[
"exec",
Expand Down
70 changes: 70 additions & 0 deletions src/takopi/runners/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
from __future__ import annotations

import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal

import msgspec

from ..agent_modes import AgentModeCapabilities
from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError
from ..logging import get_logger
Expand All @@ -46,6 +48,71 @@
_RESUME_RE = re.compile(
r"(?im)^\s*`?opencode(?:\s+run)?\s+(?:--session|-s)\s+(?P<token>ses_[A-Za-z0-9]+)`?\s*$"
)
_DEFAULT_OPENCODE_MODES: tuple[str, ...] = ("build", "plan")


def _parse_agent_modes(raw: str) -> tuple[str, ...]:
found: list[str] = []
seen: set[str] = set()
for line in raw.splitlines():
match = re.match(r"^([a-z0-9_\-]{1,64})\s+\(", line.strip().lower())
if match is None:
continue
mode = match.group(1)
if mode in seen:
continue
seen.add(mode)
found.append(mode)
return tuple(found)


def discover_agent_modes(timeout_s: float) -> AgentModeCapabilities:
try:
proc = subprocess.run(
["opencode", "agent", "list"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_s,
check=False,
)
except OSError as exc:
logger.info(
"opencode.agent_modes.failed",
error=str(exc),
error_type=exc.__class__.__name__,
)
return AgentModeCapabilities(
supports_agent=True,
known_modes=_DEFAULT_OPENCODE_MODES,
)
except subprocess.TimeoutExpired:
logger.info("opencode.agent_modes.timeout", timeout_s=timeout_s)
return AgentModeCapabilities(
supports_agent=True,
known_modes=_DEFAULT_OPENCODE_MODES,
)

raw = f"{proc.stdout}\n{proc.stderr}".strip()
if proc.returncode != 0:
logger.info("opencode.agent_modes.nonzero", rc=proc.returncode)
return AgentModeCapabilities(
supports_agent=True,
known_modes=_DEFAULT_OPENCODE_MODES,
)
discovered = _parse_agent_modes(raw)
if not discovered:
logger.info("opencode.agent_modes.empty")
return AgentModeCapabilities(
supports_agent=True,
known_modes=_DEFAULT_OPENCODE_MODES,
)
return AgentModeCapabilities(
supports_agent=True,
known_modes=discovered,
shortcut_modes=discovered,
)


@dataclass(slots=True)
Expand Down Expand Up @@ -335,6 +402,8 @@ def build_args(
model = run_options.model
if model is not None:
args.extend(["--model", str(model)])
if run_options is not None and run_options.mode:
args.extend(["--agent", str(run_options.mode)])
args.extend(["--", prompt])
return args

Expand Down Expand Up @@ -499,4 +568,5 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
id="opencode",
build_runner=build_runner,
install_cmd="npm install -g opencode-ai@latest",
discover_agent_modes=discover_agent_modes,
)
1 change: 1 addition & 0 deletions src/takopi/runners/run_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
class EngineRunOptions:
model: str | None = None
reasoning: str | None = None
mode: str | None = None


_RUN_OPTIONS: ContextVar[EngineRunOptions | None] = ContextVar(
Expand Down
Loading