Merge remote-tracking branch 'origin/main' into aakash/dev-2446-openai-content-null

This commit is contained in:
Aakash Kattelu 2026-08-25 15:56:18 -04:00
commit 20544a29e8
47 changed files with 3627 additions and 186 deletions

4
.gitignore vendored
View File

@ -6,8 +6,8 @@ api/docker-compose.yml
*.db
data
redis-data
docker-compose.yml
compose.yml
/docker-compose.yml
/compose.yml

View File

@ -505,6 +505,69 @@ honcho session view [<session_id>]
</Accordion>
</AccordionGroup>
## honcho start
Start a local Honcho stack (API, deriver, Postgres, Redis).
Requires Docker. Uses cloud LLM providers. Does not change the CLI's
configured server URL — pass HONCHO_BASE_URL to talk to this stack.
``--setup basic`` or ``--setup advanced`` runs an interactive config wizard.
```bash
honcho start
```
<ParamField path="--profile" type="string" default="local">
Local stack profile name.
</ParamField>
<ParamField path="--api-port" type="string">
Host port for the API.
</ParamField>
<ParamField path="--db-port" type="string">
Host port for Postgres.
</ParamField>
<ParamField path="--redis-port" type="string">
Host port for Redis.
</ParamField>
<ParamField path="--setup" type="string">
Interactive config wizard: basic (provider/model) or advanced (embeddings, deriver, dialectic, dreams, flush).
</ParamField>
<ParamField path="--image" type="string">
Honcho image to pull and pin by digest (default: ghcr.io/plastic-labs/honcho:latest).
</ParamField>
<ParamField path="--timeout" type="string" default="180">
Seconds to wait for /health after compose up.
</ParamField>
## honcho status
Show local stack endpoints and container health.
With no ``--profile``, lists every stack under ``~/.honcho/profiles/``.
```bash
honcho status
```
<ParamField path="--profile" type="string">
Limit to this profile. Omit to show every local stack.
</ParamField>
## honcho stop
Stop the local stack started by `honcho start`. Keeps data unless --wipe.
```bash
honcho stop
```
<ParamField path="--profile" type="string" default="local">
Local stack profile name.
</ParamField>
<ParamField path="--wipe" type="boolean">
Also delete volumes (Postgres data).
</ParamField>
## honcho workspace
List, create, inspect, delete, and search workspaces.

View File

@ -22,10 +22,31 @@ uvx honcho-cli
```bash
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho start # optional: local API + deriver + Postgres + Redis (Docker)
honcho doctor # verify your config + connectivity
honcho # show banner + command list
```
## Local stack
`honcho start` runs a personal Honcho server on your machine via Docker (API, deriver, Postgres, Redis). It is not the managed service at `api.honcho.dev`. Deriver and dialectic call your cloud LLM provider (OpenAI, Anthropic, or Gemini) with a key you supply. Stack files live under `~/.honcho/profiles/local/`. The first start writes `config.toml` there from the image; later starts leave that file alone so your edits persist.
Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (environment variables win over `config.toml`). This is TTY-only. `basic` covers provider and chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and deriver flush. Re-running `--setup` while the stack is up recreates the API and deriver containers.
This does **not** change `environmentUrl` in the shared config file. To talk to the local stack:
```bash
HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list
```
```bash
LLM_OPENAI_API_KEY=sk-... honcho start
honcho start --setup basic
honcho status
honcho stop # keep data
honcho stop --wipe # also delete volumes
```
## Configuration
The CLI resolves config in this order: **flag → env var → config file → default**.

View File

@ -7,13 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [0.1.3] - 2026-08-25
### Added
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session
- `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029)
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006)
### Fixed
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window (#1006)
## [0.1.2] - 2026-07-20

View File

@ -23,6 +23,7 @@ uv tool install honcho-cli
```bash
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho start # optional: local API + deriver + Postgres + Redis (Docker)
honcho doctor # verify your config + connectivity
honcho # show banner + command list
```
@ -31,6 +32,31 @@ honcho # show banner + command list
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults.
### Local stack
`honcho start` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. Inference is cloud-side: set `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` (env overrides `config.toml`). Stack files live under `~/.honcho/profiles/local/` and are not committed to a project.
On first start, the CLI pulls `ghcr.io/plastic-labs/honcho:latest` and **pins that digest** in `profile.json`, then copies the image's `config.toml.example` to `config.toml` in the same directory. `honcho start` never overwrites `config.toml` after that — including when you re-pin the image. Delete the file yourself if you want a fresh copy from a new image.
Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (env wins over `config.toml`). TTY only; re-runnable. `basic` asks provider + chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and snappy deriver flush. Everything else stays in `config.toml`.
`honcho start` does **not** change `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). To talk to the local stack for one command:
```bash
HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list
```
To make local the default, run `honcho init --base-url http://127.0.0.1:8000`.
```bash
LLM_OPENAI_API_KEY=sk-... honcho start
honcho start --setup basic
honcho start --setup advanced
honcho status
honcho stop # keep data
honcho stop --wipe # also delete volumes
```
## Commands
### Onboarding
@ -38,6 +64,9 @@ Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-
| Command | Description |
|---------|-------------|
| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` |
| `honcho start` | Start a local Honcho stack (API, deriver, Postgres, Redis). Requires Docker and a cloud LLM key. `--setup basic` / `--setup advanced` runs an interactive config wizard (TTY only). Does not change `environmentUrl`. |
| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. |
| `honcho status` | Show every local stack (or `--profile` for one). |
| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue |
### Workspaces
@ -157,6 +186,8 @@ Precedence (highest first): **flag → env var → config file → default**.
| `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope |
| `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope |
| `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) |
| `HONCHO_PROFILE` | `--profile` (start/stop/status) | Local stack profile (default: `local`) |
| `LLM_OPENAI_API_KEY` | — | Provider key for `honcho start` (also `LLM_ANTHROPIC_API_KEY`, `LLM_GEMINI_API_KEY`) |
```bash
# Per-command flags

View File

@ -1,6 +1,6 @@
[project]
name = "honcho-cli"
version = "0.1.2"
version = "0.1.3"
description = "A terminal for Honcho — memory that reasons."
readme = "README.md"
requires-python = ">=3.11"
@ -38,6 +38,10 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/honcho_cli"]
[tool.hatch.build.targets.wheel.force-include]
"src/honcho_cli/local/templates/docker-compose.yml" = "honcho_cli/local/templates/docker-compose.yml"
"src/honcho_cli/local/templates/init.sql" = "honcho_cli/local/templates/init.sql"
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@ -1,3 +1,3 @@
"""Honcho CLI — a terminal for Honcho."""
__version__ = "0.1.2"
__version__ = "0.1.3"

View File

@ -67,6 +67,7 @@ def print_welcome(console: Console) -> None:
start_rows = [
("honcho init", "configure API key and server URL"),
("honcho start", "run a local Honcho stack (Docker)"),
("honcho doctor", "verify connection and workspace health"),
]
cmd_rows = [

View File

@ -0,0 +1,400 @@
"""Local stack lifecycle: ``honcho start``, ``honcho stop``, ``honcho status``.
Does not mutate ``~/.honcho/config.json``. The CLI stays pointed at whatever
``honcho init`` configured (typically api.honcho.dev). Print the local URL
and a one-shot ``HONCHO_BASE_URL=...`` hint instead.
"""
from __future__ import annotations
import typer
from rich.console import Console
from honcho_cli.branding import BRAND, ICON_FAIL, ICON_OK
from honcho_cli.local import (
DEFAULT_HEALTH_TIMEOUT,
DEFAULT_IMAGE,
DEFAULT_PROFILE,
STACK_SERVICES,
)
from honcho_cli.local.docker import (
DockerError,
allocate_host_ports,
compose_down,
compose_ps,
compose_up,
pin_image,
seed_config_toml,
services_running,
)
from honcho_cli.local.env import has_provider_key, render_stack, settings_from_environ
from honcho_cli.local.health import stack_healthy, wait_for_health
from honcho_cli.local.profile import (
LocalProfile,
list_profile_names,
load_profile,
resolve_profile_name,
save_profile,
)
from honcho_cli.local.setup import (
SETUP_MODES,
answers_drop_keys,
answers_to_env,
run_setup,
)
from honcho_cli.output import (
fail,
ok,
print_error,
print_json,
print_result,
set_json_mode,
step,
use_json,
)
_console = Console(stderr=True)
_MISSING_LLM_KEY = (
"Set LLM_OPENAI_API_KEY, LLM_ANTHROPIC_API_KEY, or LLM_GEMINI_API_KEY, "
"or run honcho start --setup basic."
)
def _die(code: str, message: str, details: dict | None = None) -> None:
print_error(code, message, details)
raise typer.Exit(1)
def _validate_setup(setup: str | None) -> str | None:
if setup is None:
return None
mode = setup.strip().lower()
if mode not in SETUP_MODES:
_die(
"INVALID_SETUP",
f"Unknown setup mode {setup!r}. Use --setup basic or --setup advanced.",
{"setup": setup},
)
if use_json():
_die(
"SETUP_REQUIRES_TTY",
"honcho start --setup is interactive. Run it in a terminal without --json.",
{"setup": mode},
)
return mode
def _payload(
profile: LocalProfile, status: str, services: dict[str, str] | None = None
) -> dict:
return {
"profile": profile.name,
"status": status,
"image": profile.image,
"endpoints": profile.endpoints(),
"services": services or {},
"hint": f"HONCHO_BASE_URL={profile.base_url} honcho workspace list",
}
def _print_stack(payload: dict) -> None:
if use_json():
print_json(payload)
return
endpoints = payload["endpoints"]
_console.print()
table_data = {
"API": endpoints["api"],
"Docs": endpoints["docs"],
"Postgres": endpoints["postgres"],
"Redis": endpoints["redis"],
}
print_result(table_data)
_console.print()
_console.print(
" [dim]CLI still points at your configured server (typically api.honcho.dev).[/dim]"
)
_console.print(f" [dim]To talk to this stack:[/dim] {payload['hint']}")
_console.print()
def _print_running(profile: LocalProfile) -> None:
_print_stack(_payload(profile, "running", services_running(compose_ps(profile))))
def _seed_config(profile: LocalProfile) -> None:
if seed_config_toml(profile):
ok("config.toml")
def _inspect(profile: LocalProfile) -> tuple[dict[str, str], bool]:
"""Compose service states and whether the API is healthy."""
return services_running(compose_ps(profile)), stack_healthy(profile)
def start(
profile_name: str = typer.Option(
DEFAULT_PROFILE,
"--profile",
envvar="HONCHO_PROFILE",
help="Local stack profile name",
),
api_port: int | None = typer.Option(
None, "--api-port", min=1, max=65535, help="Host port for the API"
),
db_port: int | None = typer.Option(
None, "--db-port", min=1, max=65535, help="Host port for Postgres"
),
redis_port: int | None = typer.Option(
None, "--redis-port", min=1, max=65535, help="Host port for Redis"
),
setup: str | None = typer.Option(
None,
"--setup",
help="Interactive config wizard: basic (provider/model) or advanced "
"(embeddings, deriver, dialectic, dreams, flush)",
),
image: str | None = typer.Option(
None,
"--image",
help=f"Honcho image to pull and pin by digest (default: {DEFAULT_IMAGE})",
),
timeout: int = typer.Option(
DEFAULT_HEALTH_TIMEOUT,
"--timeout",
min=1,
help="Seconds to wait for /health after compose up",
),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Start a local Honcho stack (API, deriver, Postgres, Redis).
Requires Docker. Uses cloud LLM providers. Does not change the CLI's
configured server URL pass HONCHO_BASE_URL to talk to this stack.
``--setup basic`` or ``--setup advanced`` runs an interactive config wizard.
"""
if json_output:
set_json_mode(True)
setup = _validate_setup(setup)
name = resolve_profile_name(profile_name)
profile = load_profile(name).overlay(
api_port=api_port,
db_port=db_port,
redis_port=redis_port,
image=image,
)
pinned_ports = frozenset(
name
for name, value in (
("api", api_port),
("database", db_port),
("redis", redis_port),
)
if value is not None
)
if not use_json():
_console.print(f"\n[bold {BRAND}]Honcho Start[/bold {BRAND}]\n")
try:
already_running = stack_healthy(profile)
if already_running and not setup:
ok(f"Already running ({profile.base_url})")
_print_running(profile)
return
if not already_running:
profile, remapped = allocate_host_ports(profile, pinned=pinned_ports)
for service, (old, new) in remapped.items():
step(f"Port {old} in use; {service} on {new}")
step(f"Pinning {profile.image}")
pinned_image = pin_image(profile.image)
profile = profile.overlay(image=pinned_image)
ok(pinned_image)
extra = settings_from_environ()
drop: tuple[str, ...] = ()
_seed_config(profile)
if setup:
answers = run_setup(
setup,
profile.env_file(),
config_path=profile.config_file(),
)
extra.update(answers_to_env(answers))
drop = answers_drop_keys(answers)
ok(f"Wrote overrides to {profile.env_file()}")
_console.print(
f" [dim]Other settings live in {profile.config_file()}[/dim]"
)
elif not has_provider_key(profile, extra):
_die("MISSING_LLM_KEY", _MISSING_LLM_KEY)
step(f"Writing stack config to {profile.dir()}")
save_profile(profile)
render_stack(profile, extra=extra, drop=drop)
ok(f"Profile '{profile.name}'")
step("Starting containers" if not already_running else "Recreating api + deriver")
compose_up(
profile,
recreate=("api", "deriver") if already_running else (),
)
step(f"Waiting for API at {profile.base_url}/health")
if not wait_for_health(profile, timeout=float(timeout)):
fail("Timed out waiting for /health")
_die(
"HEALTH_TIMEOUT",
f"Stack started but {profile.base_url}/health did not become ready within {timeout}s. "
f"Check `docker compose -p {profile.project_name} logs`.",
{
"base_url": profile.base_url,
"timeout": timeout,
"project": profile.project_name,
},
)
ok("Honcho is running")
_print_running(profile)
except DockerError as e:
e.exit()
def stop(
profile_name: str = typer.Option(
DEFAULT_PROFILE,
"--profile",
envvar="HONCHO_PROFILE",
help="Local stack profile name",
),
wipe: bool = typer.Option(
False, "--wipe", help="Also delete volumes (Postgres data)"
),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Stop the local stack started by `honcho start`. Keeps data unless --wipe."""
if json_output:
set_json_mode(True)
name = resolve_profile_name(profile_name)
profile = load_profile(name)
try:
if not profile.compose_file().exists():
payload = _payload(profile, "stopped")
if use_json():
print_json(payload)
else:
_console.print(
f" [dim]No local stack for profile '{profile.name}'.[/dim]"
)
return
running = bool(compose_ps(profile))
if not running and not wipe:
if use_json():
print_json(_payload(profile, "stopped"))
else:
_console.print(
f" [dim]Profile '{profile.name}' is already stopped.[/dim]"
)
return
compose_down(profile, wipe=wipe)
except DockerError as e:
e.exit()
state = "wiped" if wipe else "stopped"
ok(f"Stopped profile '{profile.name}'" + (" (volumes removed)" if wipe else ""))
if use_json():
print_json(_payload(profile, state))
def _status_one(profile: LocalProfile) -> bool:
"""Print one profile's status. Return True when the API is healthy."""
try:
services, running = _inspect(profile)
except DockerError as e:
e.exit()
data = _payload(profile, "running" if running else "stopped", services)
if not use_json():
icon = ICON_OK if running else ICON_FAIL
_console.print(f"\n {icon} profile '{profile.name}' is {data['status']}\n")
if services:
for svc in STACK_SERVICES:
detail = services.get(svc, "missing")
_console.print(f" {svc:<10} [dim]{detail}[/dim]")
_print_stack(data)
return running
def status(
profile_name: str | None = typer.Option(
None,
"--profile",
envvar="HONCHO_PROFILE",
help="Limit to this profile. Omit to show every local stack.",
),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Show local stack endpoints and container health.
With no ``--profile``, lists every stack under ``~/.honcho/profiles/``.
"""
if json_output:
set_json_mode(True)
if profile_name:
name = resolve_profile_name(profile_name)
profile = load_profile(name)
if not profile.compose_file().exists():
_die(
"STACK_NOT_FOUND",
f"No local stack for profile '{profile.name}'. Run `honcho start` first.",
{"profile": profile.name},
)
if not _status_one(profile):
raise typer.Exit(1)
return
names = list_profile_names()
if not names:
_die(
"STACK_NOT_FOUND",
"No local stacks. Run `honcho start` first.",
)
if len(names) == 1:
if not _status_one(load_profile(names[0])):
raise typer.Exit(1)
return
rows: list[dict] = []
try:
for name in names:
profile = load_profile(name)
services, running = _inspect(profile)
rows.append(
_payload(profile, "running" if running else "stopped", services)
)
except DockerError as e:
e.exit()
if use_json():
print_json({"profiles": rows})
return
_console.print()
print_result(
[
{
"profile": row["profile"],
"status": row["status"],
"api": row["endpoints"]["api"],
}
for row in rows
],
columns=["profile", "status", "api"],
)

View File

@ -0,0 +1,12 @@
"""Local Honcho stack: profiles, Compose rendering, Docker, health checks."""
from __future__ import annotations
DEFAULT_PROFILE = "local"
DEFAULT_API_PORT = 8000
DEFAULT_DB_PORT = 5432
DEFAULT_REDIS_PORT = 6379
DEFAULT_IMAGE = "ghcr.io/plastic-labs/honcho:latest"
DEFAULT_HEALTH_TIMEOUT = 180
STACK_SERVICES = ("api", "deriver", "database", "redis")

View File

@ -0,0 +1,436 @@
"""Docker daemon + Compose helpers for the local stack."""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from honcho_cli.local import STACK_SERVICES
from honcho_cli.local.profile import LocalProfile
from honcho_cli.output import print_error
_DAEMON_DOWN_MARKERS = (
"cannot connect to the docker daemon",
"is the docker daemon running",
"failed to connect to the docker api",
"error during connect",
)
_COMPOSE_MISSING_MARKERS = (
"'compose' is not a docker command",
"unknown command: compose",
"docker: unknown command",
)
_CRED_HELPER_MARKERS = ("error getting credentials", "docker-credential-desktop")
class DockerError(Exception):
"""Docker is missing, the daemon is down, or a Compose command failed."""
def __init__(self, code: str, message: str, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
def exit(self) -> None:
print_error(self.code, self.message, self.details or None)
raise SystemExit(1)
def port_available(port: int, host: str = "127.0.0.1") -> bool:
"""True when nothing is accepting connections on ``host:port``."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.2)
return sock.connect_ex((host, port)) != 0
def allocate_host_ports(
profile: LocalProfile,
*,
pinned: frozenset[str] = frozenset(),
) -> tuple[LocalProfile, dict[str, tuple[int, int]]]:
"""Move api/db/redis host ports that are already bound.
Names in ``pinned`` (``api`` / ``database`` / ``redis``) were set by a
flag and fail instead of moving.
"""
taken: set[int] = set()
remapped: dict[str, tuple[int, int]] = {}
chosen: dict[str, int] = {}
for name, field, flag in (
("api", "api_port", "--api-port"),
("database", "db_port", "--db-port"),
("redis", "redis_port", "--redis-port"),
):
preferred = getattr(profile, field)
port = preferred
if name in pinned:
if preferred in taken or not port_available(preferred):
raise DockerError(
"PORT_IN_USE",
f"Host port {preferred} for {name} is already in use. "
f"Pass {flag} with a free port, or stop the other process.",
{"port": preferred, "service": name, "flag": flag},
)
else:
while port in taken or not port_available(port):
port += 1
if port > preferred + 100:
raise DockerError(
"PORT_IN_USE",
f"Could not find a free host port near {preferred}.",
{"preferred": preferred},
)
if port != preferred:
remapped[name] = (preferred, port)
taken.add(port)
chosen[field] = port
return profile.overlay(**chosen), remapped
def compose_argv(profile: LocalProfile) -> list[str]:
return [
"docker",
"compose",
"-f",
str(profile.compose_file()),
"--project-directory",
str(profile.dir()),
"-p",
profile.project_name,
]
_CONFIG_PATHS = ("/app/config.toml.example", "/app/config.toml")
_CONFIG_HEADER = (
"# Copied from {image} by honcho start. This file is not overwritten on later starts.\n"
"# Secrets belong in .env (environment variables win over this file).\n\n"
)
def image_is_digest(ref: str) -> bool:
"""True when ``ref`` is already pinned to a content digest."""
return "@sha256:" in ref.lower()
def image_repository(ref: str) -> str:
"""Strip a tag or digest from a Docker image reference."""
if "@" in ref:
return ref.split("@", 1)[0]
last_slash = ref.rfind("/")
last_colon = ref.rfind(":")
if last_colon > last_slash:
return ref[:last_colon]
return ref
def pin_image(image: str) -> str:
"""Pull ``image`` if needed and return a digest-pinned reference.
``ghcr.io/plastic-labs/honcho:latest`` becomes
``ghcr.io/plastic-labs/honcho@sha256:...`` so the profile does not
float when ``:latest`` moves. Already-pinned refs are left alone.
"""
if image_is_digest(image):
if not _image_exists(image):
_pull(image)
return image
_pull(image)
digest = _repo_digest(image)
if not digest:
raise DockerError(
"IMAGE_PIN_FAILED",
f"Pulled {image} but could not resolve a registry digest to pin.",
{"image": image},
)
return digest
def seed_config_toml(profile: LocalProfile) -> bool:
"""Copy the image's ``config.toml.example`` into the profile if missing.
Returns True when a file was written. Never overwrites an existing
``config.toml``.
"""
dest = profile.config_file()
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
return False
copied = _copy_from_image(profile.image, _CONFIG_PATHS)
if copied is None:
raise DockerError(
"CONFIG_MISSING",
f"Could not copy config.toml from {profile.image}.",
{"image": profile.image},
)
dest.write_text(_CONFIG_HEADER.format(image=profile.image) + copied)
return True
def compose_up(
profile: LocalProfile,
*,
recreate: tuple[str, ...] = (),
) -> None:
"""``docker compose up -d``. Compose output goes to stderr.
``recreate`` names services to ``--force-recreate`` (used after ``--setup``
on an already-running stack so new ``.env`` values take effect).
"""
args = ["up", "-d"]
if recreate:
args.extend(["--force-recreate", *recreate])
_run_compose(profile, args)
def compose_down(profile: LocalProfile, *, wipe: bool = False) -> None:
args = ["down"]
if wipe:
args.append("-v")
_run_compose(profile, args, capture=False)
def compose_ps(profile: LocalProfile) -> list[dict]:
"""Parsed ``docker compose ps --format json`` (array or NDJSON)."""
proc = _run_compose(profile, ["ps", "--format", "json"], capture=True, check=False)
if proc.returncode != 0:
return []
return _parse_ps(proc.stdout or "")
def services_running(ps: list[dict]) -> dict[str, str]:
"""Map service name → state for the four stack services.
State is ``running``, ``healthy``, ``exited``, etc. Prefer Docker's
Health field when present.
"""
out: dict[str, str] = {}
for row in ps:
service = str(row.get("Service") or row.get("Name") or "")
# "honcho-local-api-1" → try Service first; fall back to suffix match
if service not in STACK_SERVICES:
for name in STACK_SERVICES:
if (
service == name
or service.endswith(f"-{name}-1")
or f"_{name}_" in service
):
service = name
break
else:
continue
health = str(row.get("Health") or "").lower()
state = str(row.get("State") or row.get("Status") or "").lower()
if health:
out[service] = health
elif "health" in state:
# e.g. "running (healthy)"
out[service] = state
else:
out[service] = state or "unknown"
return out
def stack_containers_up(ps: list[dict]) -> bool:
"""True when all four services are running (deriver has no healthcheck)."""
states = services_running(ps)
if any(name not in states for name in STACK_SERVICES):
return False
for state in states.values():
if "exit" in state or state in {"dead", "paused"}:
return False
if "running" not in state and "healthy" not in state:
return False
return True
def _unavailable(proc: subprocess.CompletedProcess[str]) -> DockerError | None:
"""Map a failed docker/compose process to a user-facing error, if obvious."""
text = f"{proc.stderr or ''}{proc.stdout or ''}"
lower = text.lower()
if any(marker in lower for marker in _DAEMON_DOWN_MARKERS):
return DockerError(
"DOCKER_NOT_RUNNING",
"Docker is installed but the daemon is not running. Start it and retry.",
)
if any(marker in lower for marker in _COMPOSE_MISSING_MARKERS):
return DockerError(
"DOCKER_COMPOSE_MISSING",
"Honcho start requires Docker Compose v2 (the `docker compose` plugin).",
)
if any(marker in text for marker in _CRED_HELPER_MARKERS):
return DockerError(
"DOCKER_CREDENTIALS",
"Docker could not read registry credentials "
"(docker-credential-desktop is not on PATH). "
"Quit and reopen your terminal, or add Docker Desktop's bin "
"directory to PATH, then retry.",
{"exit_code": proc.returncode},
)
return None
def _run_compose(
profile: LocalProfile,
args: list[str],
*,
capture: bool = False,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
cmd = compose_argv(profile) + args
cwd: Path = profile.dir()
try:
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
except FileNotFoundError as e:
raise DockerError(
"DOCKER_NOT_INSTALLED",
"Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.",
) from e
except OSError as e:
raise DockerError("COMPOSE_FAILED", str(e), {"command": cmd}) from e
if not capture:
if proc.stdout:
sys.stderr.write(proc.stdout)
if proc.stderr:
sys.stderr.write(proc.stderr)
if proc.returncode != 0:
classified = _unavailable(proc)
if classified is not None:
raise classified
if check and proc.returncode != 0:
raise DockerError(
"COMPOSE_FAILED",
"docker compose failed. See output above, or run `docker compose -p "
f"{profile.project_name} logs`.",
{"project": profile.project_name, "exit_code": proc.returncode},
)
return proc
def _run_docker(
args: list[str],
*,
check: bool = False,
) -> subprocess.CompletedProcess[str]:
try:
proc = subprocess.run(
["docker", *args],
capture_output=True,
text=True,
)
except FileNotFoundError as e:
raise DockerError(
"DOCKER_NOT_INSTALLED",
"Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.",
) from e
except OSError as e:
raise DockerError("DOCKER_FAILED", str(e), {"command": args}) from e
if proc.returncode == 0:
return proc
classified = _unavailable(proc)
if classified is not None:
raise classified
if check:
raise DockerError(
"DOCKER_FAILED",
f"docker {' '.join(args)} failed.",
{
"exit_code": proc.returncode,
"stderr": (proc.stderr or "")[-500:],
},
)
return proc
def _pull(image: str) -> None:
proc = _run_docker(["pull", image], check=False)
if proc.stdout:
sys.stderr.write(proc.stdout)
if proc.stderr:
sys.stderr.write(proc.stderr)
if proc.returncode != 0:
raise DockerError(
"IMAGE_PULL_FAILED",
f"Failed to pull {image}.",
{"image": image, "exit_code": proc.returncode},
)
def _image_exists(image: str) -> bool:
return _run_docker(["image", "inspect", image], check=False).returncode == 0
def _repo_digest(image: str) -> str | None:
proc = _run_docker(
["image", "inspect", "--format", "{{json .RepoDigests}}", image],
check=False,
)
if proc.returncode != 0:
return None
try:
digests = json.loads((proc.stdout or "").strip() or "[]")
except json.JSONDecodeError:
return None
if not isinstance(digests, list):
return None
repo = image_repository(image)
for item in digests:
if isinstance(item, str) and item.startswith(repo + "@"):
return item
for item in digests:
if isinstance(item, str) and "@sha256:" in item:
return item
return None
def _copy_from_image(image: str, paths: tuple[str, ...]) -> str | None:
"""Create a stopped container and copy the first path that exists."""
name = f"honcho-seed-{os.getpid()}-{time.time_ns()}"
created = _run_docker(["create", "--name", name, image], check=False)
if created.returncode != 0:
cid = (created.stdout or "").strip() or name
_run_docker(["rm", "-f", cid], check=False)
return None
cid = (created.stdout or "").strip() or name
try:
with tempfile.TemporaryDirectory(prefix="honcho-cfg-") as tmp:
dest = Path(tmp) / "config.toml"
for path in paths:
if dest.exists():
dest.unlink()
copied = _run_docker(["cp", f"{cid}:{path}", str(dest)], check=False)
if copied.returncode == 0 and dest.exists():
return dest.read_text(encoding="utf-8")
finally:
_run_docker(["rm", "-f", cid], check=False)
return None
def _parse_ps(stdout: str) -> list[dict]:
text = stdout.strip()
if not text:
return []
if text.startswith("["):
try:
data = json.loads(text)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
rows: list[dict] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row, dict):
rows.append(row)
return rows

View File

@ -0,0 +1,183 @@
"""Render Compose + ``.env`` for a local stack profile."""
from __future__ import annotations
import os
from contextlib import suppress
from importlib.resources import files
from pathlib import Path
from honcho_cli.local.profile import LocalProfile
# Keys honcho start owns. Unknown lines in an existing .env are preserved.
MANAGED_KEYS = (
"AUTH_USE_AUTH",
"LOG_LEVEL",
"HONCHO_IMAGE",
"API_PORT",
"DB_PORT",
"REDIS_PORT",
)
# Host env forwarded into the profile .env (overrides config.toml).
_SETTINGS_PREFIXES = (
"LLM_",
"EMBEDDING_",
"DERIVER_",
"DIALECTIC_",
"DREAM_",
"SUMMARY_",
)
_LLM_KEYS = (
"LLM_OPENAI_API_KEY",
"LLM_ANTHROPIC_API_KEY",
"LLM_GEMINI_API_KEY",
)
_HEADER = (
"# Generated by honcho start. Extra keys below the managed block are preserved."
)
_PLACEHOLDERS = frozenset(
{
"",
"your-api-key-here",
"changeme",
"sk-...",
}
)
def is_placeholder_key(value: str | None) -> bool:
"""True when ``value`` is missing or a known template placeholder."""
if value is None:
return True
return value.strip() in _PLACEHOLDERS
def settings_from_environ() -> dict[str, str]:
"""Host env vars that map to Honcho settings. Empty/placeholder values skipped."""
return {
k: v
for k, v in os.environ.items()
if k.startswith(_SETTINGS_PREFIXES) and not is_placeholder_key(v)
}
def read_env_file(path: Path) -> dict[str, str]:
"""Parse a dotenv file into a dict. Last assignment of a key wins."""
if not path.exists():
return {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return {}
out: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
k, _, v = stripped.partition("=")
out[k.strip()] = _unquote(v.strip())
return out
def read_env_value(path: Path, key: str) -> str | None:
"""Return the raw value for ``key`` in a dotenv file, or None."""
return read_env_file(path).get(key)
def has_provider_key(profile: LocalProfile, extra: dict[str, str]) -> bool:
"""True when host extra or profile ``.env`` has a real LLM API key."""
stored = {**read_env_file(profile.env_file()), **extra}
return any(not is_placeholder_key(stored.get(k)) for k in _LLM_KEYS)
def managed_env(profile: LocalProfile) -> dict[str, str]:
"""Values written into the managed block of ``.env``."""
return {
"AUTH_USE_AUTH": "false",
"LOG_LEVEL": "INFO",
"HONCHO_IMAGE": profile.image,
"API_PORT": str(profile.api_port),
"DB_PORT": str(profile.db_port),
"REDIS_PORT": str(profile.redis_port),
}
def upsert_env(
path: Path,
updates: dict[str, str],
*,
drop: tuple[str, ...] = (),
) -> None:
"""Write ``updates``, preserving unrelated user lines.
Managed keys are written first (stable order), then any other keys in
``updates``. Keys in ``drop`` are removed and not rewritten. Drops a
previous generated header so it is not duplicated.
"""
drop_set = frozenset(drop)
extras: list[str] = []
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped == _HEADER or stripped.startswith(
"# Generated by honcho start"
):
continue
if not stripped or stripped.startswith("#"):
extras.append(line)
continue
if "=" in stripped:
k, _, _ = stripped.partition("=")
name = k.strip()
if name in updates or name in MANAGED_KEYS or name in drop_set:
continue
extras.append(line)
managed = [f"{k}={updates[k]}" for k in MANAGED_KEYS if k in updates]
extra_updates = [
f"{k}={v}" for k, v in updates.items() if k not in MANAGED_KEYS
]
body = [_HEADER, *managed]
if extra_updates:
if not body[-1].startswith("#"):
body.append("")
body.extend(extra_updates)
if extras:
# Keep a blank line between generated and user keys when there are extras.
if extras[0].strip():
body.append("")
body.extend(extras)
path.write_text("\n".join(body) + "\n")
with suppress(OSError):
os.chmod(path, 0o600)
def render_stack(
profile: LocalProfile,
extra: dict[str, str] | None = None,
drop: tuple[str, ...] = (),
) -> None:
"""Write compose, init.sql, and .env into the profile directory."""
directory = profile.dir()
directory.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
os.chmod(directory, 0o700)
templates = files("honcho_cli.local.templates")
compose = templates.joinpath("docker-compose.yml").read_text(encoding="utf-8")
init_sql = templates.joinpath("init.sql").read_text(encoding="utf-8")
profile.compose_file().write_text(compose)
(directory / "init.sql").write_text(init_sql)
updates = managed_env(profile)
if extra:
updates.update(extra)
upsert_env(profile.env_file(), updates, drop=drop)
def _unquote(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value

View File

@ -0,0 +1,53 @@
"""Poll the local API health endpoint."""
from __future__ import annotations
import time
import httpx
from honcho_cli.local.docker import compose_ps, services_running, stack_containers_up
from honcho_cli.local.profile import LocalProfile
def api_healthy(base_url: str, *, timeout: float = 2.0) -> bool:
"""True when ``GET /health`` returns HTTP 200."""
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(base_url.rstrip("/") + "/health")
return response.status_code == 200
except httpx.HTTPError:
return False
def stack_healthy(profile: LocalProfile) -> bool:
"""True when Compose services are up and the API answers /health."""
if not profile.compose_file().exists():
return False
ps = compose_ps(profile)
if not stack_containers_up(ps):
return False
return api_healthy(profile.base_url)
def wait_for_health(
profile: LocalProfile,
*,
timeout: float,
interval: float = 1.0,
) -> bool:
"""Poll until the API is healthy or ``timeout`` seconds elapse.
Returns False on timeout. Fails fast if a required container has exited.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
ps = compose_ps(profile)
states = services_running(ps)
for _name, state in states.items():
if "exit" in state or state in {"dead"}:
return False
if api_healthy(profile.base_url) and stack_containers_up(ps):
return True
time.sleep(interval)
return api_healthy(profile.base_url)

View File

@ -0,0 +1,157 @@
"""Named local-stack profiles under ``$HONCHO_CONFIG_DIR/profiles``.
A profile is a Compose project directory, not an auth identity.
Resolution: ``--profile`` > ``HONCHO_PROFILE`` > ``local``.
"""
from __future__ import annotations
import json
import os
import re
from contextlib import suppress
from dataclasses import dataclass, replace
from honcho_cli.local import (
DEFAULT_API_PORT,
DEFAULT_DB_PORT,
DEFAULT_IMAGE,
DEFAULT_PROFILE,
DEFAULT_REDIS_PORT,
)
from honcho_cli.output import print_error
_PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
def profiles_dir():
from honcho_cli import config as cfg
return cfg.CONFIG_DIR / "profiles"
def validate_profile_name(name: str) -> str:
if name and _PROFILE_NAME.match(name):
return name
print_error(
"INVALID_PROFILE",
"Profile name must be lowercase alphanumeric, starting with a letter "
"(hyphens and underscores allowed).",
{"profile": name},
)
raise SystemExit(1)
def resolve_profile_name(flag: str | None) -> str:
raw = (
(flag or "").strip()
or (os.environ.get("HONCHO_PROFILE") or "").strip()
or DEFAULT_PROFILE
)
return validate_profile_name(raw)
def list_profile_names() -> list[str]:
"""Profile directories that already have a Compose file."""
root = profiles_dir()
if not root.is_dir():
return []
names: list[str] = []
for path in sorted(root.iterdir()):
if (
path.is_dir()
and _PROFILE_NAME.match(path.name)
and (path / "docker-compose.yml").exists()
):
names.append(path.name)
return names
@dataclass
class LocalProfile:
"""Ports and image for one local stack."""
name: str
api_port: int = DEFAULT_API_PORT
db_port: int = DEFAULT_DB_PORT
redis_port: int = DEFAULT_REDIS_PORT
image: str = DEFAULT_IMAGE
@property
def project_name(self) -> str:
return f"honcho-{self.name}"
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.api_port}"
def dir(self):
return profiles_dir() / self.name
def compose_file(self):
return self.dir() / "docker-compose.yml"
def env_file(self):
return self.dir() / ".env"
def profile_file(self):
return self.dir() / "profile.json"
def config_file(self):
return self.dir() / "config.toml"
def endpoints(self) -> dict[str, str]:
return {
"api": self.base_url,
"docs": f"{self.base_url}/docs",
"postgres": f"postgresql://postgres:postgres@127.0.0.1:{self.db_port}/postgres",
"redis": f"redis://127.0.0.1:{self.redis_port}/0",
}
def overlay(self, **fields) -> LocalProfile:
return replace(self, **{k: v for k, v in fields.items() if v is not None})
def load_profile(name: str) -> LocalProfile:
profile = LocalProfile(name=validate_profile_name(name))
path = profile.profile_file()
if not path.exists():
return profile
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return profile
if not isinstance(data, dict):
return profile
image = data.get("image")
return replace(
profile,
api_port=_port(data.get("apiPort"), profile.api_port),
db_port=_port(data.get("dbPort"), profile.db_port),
redis_port=_port(data.get("redisPort"), profile.redis_port),
image=image if isinstance(image, str) and image else profile.image,
)
def save_profile(profile: LocalProfile) -> None:
directory = profile.dir()
directory.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
os.chmod(directory, 0o700)
payload = {
"apiPort": profile.api_port,
"dbPort": profile.db_port,
"redisPort": profile.redis_port,
"image": profile.image,
}
profile.profile_file().write_text(json.dumps(payload, indent=2) + "\n")
def _port(value: object, default: int) -> int:
if isinstance(value, bool):
return default
try:
parsed = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return parsed if 1 <= parsed <= 65535 else default

View File

@ -0,0 +1,469 @@
"""Interactive ``honcho start --setup`` wizard.
Writes curated LLM/feature overrides for the local stack. Secrets and knobs
go to the profile ``.env`` (env wins over ``config.toml``). Prompts are TTY
only the start command rejects ``--setup`` in JSON / non-TTY mode.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
import typer
from rich.console import Console
from honcho_cli.local.env import (
is_placeholder_key,
read_env_file,
settings_from_environ,
)
from honcho_cli.output import print_error
SETUP_MODES = ("basic", "advanced")
DIALECTIC_LEVELS = ("minimal", "low", "medium", "high", "max")
PROVIDERS = ("openai", "anthropic", "gemini", "openai-compatible")
EMBEDDING_TRANSPORTS = ("openai", "gemini")
_CHAT_PREFIXES = (
"DERIVER_MODEL_CONFIG",
"SUMMARY_MODEL_CONFIG",
"DREAM_DEDUCTION_MODEL_CONFIG",
"DREAM_INDUCTION_MODEL_CONFIG",
*(f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG" for level in DIALECTIC_LEVELS),
)
_PROVIDER_KEY_ENV = {
"openai": "LLM_OPENAI_API_KEY",
"openai-compatible": "LLM_OPENAI_API_KEY",
"anthropic": "LLM_ANTHROPIC_API_KEY",
"gemini": "LLM_GEMINI_API_KEY",
}
_console = Console(stderr=True)
@dataclass(frozen=True)
class TomlSetupDefaults:
"""Model/feature defaults copied from the image ``config.toml``.
Honcho only ships OpenAI chat/embedding defaults. Other providers have
no suggested model in that file the wizard does not invent one.
"""
chat_transport: str | None = None
chat_model: str | None = None
embed_transport: str | None = None
embed_model: str | None = None
embed_dims: int | None = None
dreams_enabled: bool | None = None
flush_enabled: bool | None = None
def load_toml_setup_defaults(path: Path | None) -> TomlSetupDefaults:
"""Read prompt defaults from the profile ``config.toml`` (image-aligned)."""
if path is None or not path.is_file():
return TomlSetupDefaults()
try:
with path.open("rb") as fh:
data = tomllib.load(fh)
deriver = data.get("deriver") or {}
chat = deriver.get("model_config") or {}
embedding = data.get("embedding") or {}
embed = embedding.get("model_config") or {}
dream = data.get("dream") or {}
dims = embedding.get("VECTOR_DIMENSIONS")
return TomlSetupDefaults(
chat_transport=chat.get("transport"),
chat_model=chat.get("model"),
embed_transport=embed.get("transport"),
embed_model=embed.get("model"),
embed_dims=dims if isinstance(dims, int) and dims > 0 else None,
dreams_enabled=dream.get("ENABLED"),
flush_enabled=deriver.get("FLUSH_ENABLED"),
)
except (OSError, tomllib.TOMLDecodeError, TypeError, AttributeError):
return TomlSetupDefaults()
def chat_model_default(
provider: str,
env: dict[str, str],
toml: TomlSetupDefaults,
*,
inferred: str | None = None,
) -> str:
"""Prefer a previous wizard choice, else the image toml when transports match."""
if inferred is None:
inferred = infer_provider(env)
if inferred == provider:
current = env.get("DERIVER_MODEL_CONFIG__MODEL")
if current:
return current
if toml.chat_model and _provider_matches_transport(provider, toml.chat_transport):
return toml.chat_model
return ""
def _provider_matches_transport(provider: str, transport: str | None) -> bool:
if not transport:
return False
return transport_of(provider) == transport
@dataclass(frozen=True)
class SetupAnswers:
"""Curated knobs collected by the wizard (or tests)."""
mode: str
provider: str
api_key: str
chat_model: str
base_url: str | None = None
embedding_api_key: str | None = None
embedding_key_transport: str | None = None
embedding_transport: str | None = None
embedding_model: str | None = None
embedding_dimensions: int | None = None
deriver_model: str | None = None
dialectic_model: str | None = None
dreams_enabled: bool | None = None
flush_enabled: bool | None = None
def transport_of(provider: str) -> str:
"""Honcho ``MODEL_CONFIG.transport`` for a wizard provider id."""
return "openai" if provider == "openai-compatible" else provider
def answers_to_env(answers: SetupAnswers) -> dict[str, str]:
"""Map wizard answers to Honcho env overrides."""
transport = transport_of(answers.provider)
env: dict[str, str] = {}
env[_PROVIDER_KEY_ENV[answers.provider]] = answers.api_key
if answers.base_url:
env["LLM_OPENAI_BASE_URL"] = answers.base_url
if answers.embedding_api_key and answers.embedding_key_transport:
embed_key = (
"LLM_OPENAI_API_KEY"
if answers.embedding_key_transport == "openai"
else "LLM_GEMINI_API_KEY"
)
env[embed_key] = answers.embedding_api_key
for prefix in _CHAT_PREFIXES:
env[f"{prefix}__TRANSPORT"] = transport
env[f"{prefix}__MODEL"] = answers.chat_model
if answers.deriver_model:
env["DERIVER_MODEL_CONFIG__TRANSPORT"] = transport
env["DERIVER_MODEL_CONFIG__MODEL"] = answers.deriver_model
if answers.dialectic_model:
for level in DIALECTIC_LEVELS:
env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__TRANSPORT"] = transport
env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] = (
answers.dialectic_model
)
if answers.embedding_transport:
env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = answers.embedding_transport
if answers.embedding_model:
env["EMBEDDING_MODEL_CONFIG__MODEL"] = answers.embedding_model
if answers.embedding_dimensions is not None:
env["EMBEDDING_VECTOR_DIMENSIONS"] = str(answers.embedding_dimensions)
elif answers.embedding_key_transport == "gemini":
# Basic + Anthropic chat: a Gemini key is unused unless embeddings switch.
env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = "gemini"
if answers.dreams_enabled is not None:
env["DREAM_ENABLED"] = "true" if answers.dreams_enabled else "false"
if answers.flush_enabled is not None:
env["DERIVER_FLUSH_ENABLED"] = "true" if answers.flush_enabled else "false"
return env
def answers_drop_keys(answers: SetupAnswers) -> tuple[str, ...]:
"""Keys to remove so a previous wizard run cannot leak into this one."""
if answers.provider == "openai-compatible":
return ()
return ("LLM_OPENAI_BASE_URL",)
def run_setup(
mode: str,
env_path: Path,
*,
config_path: Path | None = None,
) -> SetupAnswers:
"""Prompt for ``basic`` or ``advanced`` knobs. Enter keeps the default."""
env = read_env_file(env_path)
env.update(settings_from_environ())
defaults = load_toml_setup_defaults(config_path)
_console.print()
_console.print(
" [dim]Configure the local stack. Press Enter to keep the default.[/dim]"
)
_console.print(
" [dim]These values go in .env (they override config.toml).[/dim]"
)
_console.print()
inferred = infer_provider(env)
provider = _choose(
"LLM provider",
[
("openai", "OpenAI"),
("anthropic", "Anthropic"),
("gemini", "Gemini"),
("openai-compatible", "OpenAI-compatible (OpenRouter, vLLM, Ollama, …)"),
],
inferred if inferred in PROVIDERS else "openai",
)
base_url: str | None = None
if provider == "openai-compatible":
base_url = _prompt_text(
"OpenAI-compatible base URL",
env.get("LLM_OPENAI_BASE_URL") or "https://openrouter.ai/api/v1",
)
key_env = _PROVIDER_KEY_ENV[provider]
api_key = _prompt_secret("API key", env.get(key_env))
chat_default = chat_model_default(
provider, env, defaults, inferred=inferred
)
chat_model = _prompt_text(
"Chat model (deriver, dialectic, summary, dream)",
chat_default,
required=True,
)
embedding_api_key: str | None = None
embedding_key_transport: str | None = None
embedding_transport: str | None = None
embedding_model: str | None = None
embedding_dimensions: int | None = None
deriver_model: str | None = None
dialectic_model: str | None = None
dreams_enabled: bool | None = None
flush_enabled: bool | None = None
if mode == "advanced":
embedding_transport = _choose(
"Embedding provider",
[("openai", "OpenAI"), ("gemini", "Gemini")],
_default_embedding_transport(provider, env, defaults),
)
same_embed = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") == embedding_transport
current_embed = env.get("EMBEDDING_MODEL_CONFIG__MODEL") if same_embed else None
embed_from_toml = (
defaults.embed_model
if defaults.embed_transport == embedding_transport
else None
)
embedding_model = (
_prompt_text("Embedding model", current_embed or embed_from_toml or "")
or None
)
dim_default = (
int(env["EMBEDDING_VECTOR_DIMENSIONS"])
if env.get("EMBEDDING_VECTOR_DIMENSIONS", "").isdigit()
else (defaults.embed_dims or 1536)
)
embedding_dimensions = _prompt_int("Embedding dimensions", dim_default)
embedding_key_transport, embedding_api_key = _embedding_key_if_needed(
provider, embedding_transport, env
)
deriver_model = _prompt_text("Deriver model", chat_model)
dialectic_model = _prompt_text("Dialectic model (all reasoning levels)", chat_model)
dreams_enabled = _choose_bool(
"Dreams (periodic deeper reasoning)",
_env_bool(
env.get("DREAM_ENABLED"),
default=True if defaults.dreams_enabled is None else defaults.dreams_enabled,
),
)
flush_enabled = _choose_bool(
"Snappy local deriver (flush work immediately, skip batching)",
_env_bool(
env.get("DERIVER_FLUSH_ENABLED"),
default=False if defaults.flush_enabled is None else defaults.flush_enabled,
),
)
elif provider == "anthropic":
embedding_key_transport = _choose(
"Embeddings (Anthropic has none — pick a provider)",
[("openai", "OpenAI"), ("gemini", "Gemini")],
"openai",
)
embed_key_env = _PROVIDER_KEY_ENV[
"openai" if embedding_key_transport == "openai" else "gemini"
]
embedding_api_key = _prompt_secret("Embedding API key", env.get(embed_key_env))
_console.print()
return SetupAnswers(
mode=mode,
provider=provider,
api_key=api_key,
chat_model=chat_model,
base_url=base_url,
embedding_api_key=embedding_api_key,
embedding_key_transport=embedding_key_transport,
embedding_transport=embedding_transport,
embedding_model=embedding_model,
embedding_dimensions=embedding_dimensions,
deriver_model=deriver_model,
dialectic_model=dialectic_model,
dreams_enabled=dreams_enabled,
flush_enabled=flush_enabled,
)
def infer_provider(env: dict[str, str]) -> str:
"""Best-effort provider from an existing profile ``.env``."""
if env.get("LLM_OPENAI_BASE_URL"):
return "openai-compatible"
transport = env.get("DERIVER_MODEL_CONFIG__TRANSPORT")
if transport in ("anthropic", "gemini", "openai"):
return transport
if env.get("LLM_ANTHROPIC_API_KEY") and not env.get("LLM_OPENAI_API_KEY"):
return "anthropic"
if env.get("LLM_GEMINI_API_KEY") and not env.get("LLM_OPENAI_API_KEY"):
return "gemini"
return "openai"
def _default_embedding_transport(
provider: str, env: dict[str, str], defaults: TomlSetupDefaults
) -> str:
current = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT")
if current in EMBEDDING_TRANSPORTS:
return current
if defaults.embed_transport in EMBEDDING_TRANSPORTS:
return defaults.embed_transport
if provider == "gemini":
return "gemini"
return "openai"
def _embedding_key_if_needed(
chat_provider: str,
embed_transport: str,
env: dict[str, str],
) -> tuple[str | None, str | None]:
"""Prompt for an embedding key when the chat provider cannot supply it."""
chat_transport = transport_of(chat_provider)
if embed_transport == chat_transport or (
chat_provider == "openai-compatible" and embed_transport == "openai"
):
return None, None
key_env = _PROVIDER_KEY_ENV[embed_transport]
key = _prompt_secret(f"{embed_transport} embedding API key", env.get(key_env))
return embed_transport, key
def _choose(label: str, options: list[tuple[str, str]], default: str) -> str:
ids = [item[0] for item in options]
default_idx = ids.index(default) + 1 if default in ids else 1
_console.print(f" [dim]{label}[/dim]")
for i, (_oid, desc) in enumerate(options, 1):
_console.print(f" [dim]({i})[/dim] {desc}")
raw = typer.prompt(
" Choice",
default=str(default_idx),
show_default=True,
prompt_suffix=": ",
).strip()
try:
idx = int(raw)
except ValueError:
if raw in ids:
return raw
return options[default_idx - 1][0]
if 1 <= idx <= len(options):
return options[idx - 1][0]
return options[default_idx - 1][0]
def _choose_bool(label: str, default: bool) -> bool:
return (
_choose(label, [("true", "On"), ("false", "Off")], "true" if default else "false")
== "true"
)
def _prompt_text(label: str, default: str, *, required: bool = False) -> str:
while True:
raw = typer.prompt(
f" {label}",
default=default,
show_default=bool(default),
prompt_suffix=": ",
).strip()
value = raw or default
if value or not required:
return value
_console.print(" [red]A model name is required[/red]")
def _prompt_int(label: str, default: int) -> int:
while True:
raw = typer.prompt(
f" {label}",
default=str(default),
show_default=True,
prompt_suffix=": ",
).strip()
try:
value = int(raw)
except ValueError:
_console.print(" [red]Enter an integer[/red]")
continue
if value > 0:
return value
_console.print(" [red]Must be a positive integer[/red]")
def _prompt_secret(label: str, current: str | None) -> str:
if current and not is_placeholder_key(current):
_console.print(f" [dim]Current {label}: {_redact(current)}[/dim]")
_console.print(" [dim](1)[/dim] Keep current key")
_console.print(" [dim](2)[/dim] Enter a new key")
choice = typer.prompt(
" Choice", default="1", show_default=True, prompt_suffix=": "
).strip()
if choice != "2":
return current
_console.print(f" [dim]{label}[/dim]")
raw = typer.prompt(
f" {label}",
default="",
show_default=False,
hide_input=True,
prompt_suffix=": ",
).strip()
if not raw or is_placeholder_key(raw):
print_error(
"MISSING_LLM_KEY",
f"{label} is required.",
)
raise typer.Exit(1)
return raw
def _redact(key: str) -> str:
if len(key) <= 4:
return "***"
return "***" + key[-4:]
def _env_bool(value: str | None, *, default: bool) -> bool:
if value is None:
return default
return value.strip().lower() in ("1", "true", "yes", "on")

View File

@ -0,0 +1 @@
"""Package data for the local stack (Compose template + Postgres init)."""

View File

@ -0,0 +1,100 @@
# Managed by `honcho start`. Re-rendered on every start — edit .env and config.toml, not this file.
#
# Images: ghcr.io/plastic-labs/honcho (API + deriver), pgvector/pgvector:pg15, redis:8.2
# Ports bind to 127.0.0.1. Auth is off (AUTH_USE_AUTH=false in .env).
services:
api:
image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest}
entrypoint: ["sh", "docker/entrypoint.sh"]
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "127.0.0.1:${API_PORT:-8000}:8000"
healthcheck:
test:
[
"CMD",
"/app/.venv/bin/python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()",
]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
volumes:
- lancedb-data:/app/lancedb_data
- ./config.toml:/app/config.toml:ro
environment:
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
- CACHE_URL=redis://redis:6379/0?suppress=true
- CACHE_ENABLED=true
env_file:
- path: .env
required: false
restart: unless-stopped
deriver:
image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest}
entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"]
depends_on:
api:
condition: service_healthy
database:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- lancedb-data:/app/lancedb_data
- ./config.toml:/app/config.toml:ro
environment:
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
- CACHE_URL=redis://redis:6379/0?suppress=true
- CACHE_ENABLED=true
env_file:
- path: .env
required: false
restart: unless-stopped
database:
image: pgvector/pgvector:pg15
restart: unless-stopped
ports:
- "127.0.0.1:${DB_PORT:-5432}:5432"
command: ["postgres", "-c", "max_connections=200"]
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST_AUTH_METHOD=trust
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- pgdata:/var/lib/postgresql/data/
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:8.2
restart: unless-stopped
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
redis-data:
lancedb-data:

View File

@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS vector;

View File

@ -64,9 +64,13 @@ def main(
# Register top-level commands
from honcho_cli.commands.setup import doctor, init
from honcho_cli.commands.stack import start, status, stop
app.command()(init)
app.command()(doctor)
app.command()(start)
app.command()(stop)
app.command()(status)
@app.command("help", hidden=True)

View File

@ -15,6 +15,8 @@ from rich.console import Console
from rich.table import Table
from rich.text import Text
from honcho_cli.branding import ICON_FAIL, ICON_OK, ICON_RUN
console = Console(stderr=True)
stdout_console = Console()
@ -106,6 +108,24 @@ def status(msg: str) -> None:
console.print(f"[dim]{msg}[/dim]")
def step(msg: str) -> None:
"""Print a progress step. No-op in JSON mode."""
if not use_json():
console.print(f" {ICON_RUN} {msg}")
def ok(msg: str) -> None:
"""Print a success line. No-op in JSON mode."""
if not use_json():
console.print(f" {ICON_OK} {msg}")
def fail(msg: str) -> None:
"""Print a failure line. No-op in JSON mode."""
if not use_json():
console.print(f" {ICON_FAIL} {msg}")
# Stable peer-color palette for transcript rendering. Brand blue first so the
# primary peer lands on brand when there's only one speaker.
_PEER_COLORS = (

View File

@ -0,0 +1,127 @@
"""Local-stack contracts: profile files, env merge, image pin, port remap."""
from __future__ import annotations
import json
import os
import subprocess
import pytest
from honcho_cli.local.docker import (
DockerError,
allocate_host_ports,
pin_image,
seed_config_toml,
)
from honcho_cli.local.env import managed_env, read_env_value, render_stack, upsert_env
from honcho_cli.local.profile import LocalProfile, load_profile, save_profile
@pytest.fixture
def cfg_dir(tmp_path, monkeypatch):
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", tmp_path / "config.json")
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
monkeypatch.delenv(k)
return tmp_path
def test_profile_roundtrip_has_no_secrets(cfg_dir):
profile = LocalProfile(
name="local",
api_port=8001,
image="ghcr.io/plastic-labs/honcho@sha256:abc",
)
save_profile(profile)
loaded = load_profile("local")
assert loaded.api_port == 8001
assert loaded.image.endswith("@sha256:abc")
on_disk = json.loads(profile.profile_file().read_text())
assert "LLM" not in json.dumps(on_disk)
assert set(on_disk) == {"apiPort", "dbPort", "redisPort", "image"}
def test_upsert_preserves_extra_env_keys(tmp_path):
path = tmp_path / ".env"
path.write_text("CUSTOM_FLAG=keep-me\n# user comment\n")
upsert_env(path, managed_env(LocalProfile(name="local")))
text = path.read_text()
assert "CUSTOM_FLAG=keep-me" in text
assert "user comment" in text
assert text.count("Generated by honcho start") == 1
def test_upsert_writes_non_managed_and_preserves_later(tmp_path):
path = tmp_path / ".env"
first = managed_env(LocalProfile(name="local"))
first["DERIVER_MODEL_CONFIG__MODEL"] = "gpt-test"
upsert_env(path, first)
upsert_env(path, managed_env(LocalProfile(name="local")))
later = path.read_text()
assert "DERIVER_MODEL_CONFIG__MODEL=gpt-test" in later
def test_render_stack_uses_published_image(cfg_dir):
profile = LocalProfile(name="local")
render_stack(profile)
compose = profile.compose_file().read_text()
assert "ghcr.io/plastic-labs/honcho" in compose
assert "build:" not in compose
assert compose.count("./config.toml:/app/config.toml:ro") == 2
assert read_env_value(profile.env_file(), "AUTH_USE_AUTH") == "false"
assert oct(profile.env_file().stat().st_mode)[-3:] == "600"
def test_pin_latest_to_matching_digest(monkeypatch):
pulls: list[str] = []
def fake_run(args, *, check=False):
if args[:1] == ["pull"]:
pulls.append(args[1])
return subprocess.CompletedProcess(args, 0, stdout="", stderr="")
if args[:2] == ["image", "inspect"]:
body = json.dumps(
[
"ghcr.io/plastic-labs/honcho@sha256:deadbeef",
"ghcr.io/other/honcho@sha256:nope",
]
)
return subprocess.CompletedProcess(args, 0, stdout=body, stderr="")
raise AssertionError(args)
monkeypatch.setattr("honcho_cli.local.docker._run_docker", fake_run)
assert pin_image("ghcr.io/plastic-labs/honcho:latest") == (
"ghcr.io/plastic-labs/honcho@sha256:deadbeef"
)
assert pulls == ["ghcr.io/plastic-labs/honcho:latest"]
def test_seed_config_toml_writes_once(cfg_dir, monkeypatch):
profile = LocalProfile(
name="local", image="ghcr.io/plastic-labs/honcho@sha256:abc"
)
monkeypatch.setattr(
"honcho_cli.local.docker._copy_from_image",
lambda image, paths: "[deriver]\nWORKERS = 2\n",
)
assert seed_config_toml(profile) is True
profile.config_file().write_text(
profile.config_file().read_text() + "# user edit\n"
)
assert seed_config_toml(profile) is False
assert "# user edit" in profile.config_file().read_text()
def test_busy_port_remaps_unless_pinned(monkeypatch):
monkeypatch.setattr(
"honcho_cli.local.docker.port_available",
lambda port, host="127.0.0.1": port != 6379,
)
profile, remapped = allocate_host_ports(LocalProfile(name="local"))
assert profile.redis_port == 6380
assert remapped["redis"] == (6379, 6380)
with pytest.raises(DockerError) as exc:
allocate_host_ports(LocalProfile(name="local"), pinned=frozenset({"redis"}))
assert exc.value.code == "PORT_IN_USE"
assert exc.value.details["flag"] == "--redis-port"

View File

@ -0,0 +1,66 @@
"""Wizard mapping: ``answers_to_env`` and image-toml defaults."""
from __future__ import annotations
from honcho_cli.local.setup import (
DIALECTIC_LEVELS,
SetupAnswers,
answers_to_env,
chat_model_default,
load_toml_setup_defaults,
)
def test_basic_openai_applies_chat_model_everywhere():
env = answers_to_env(
SetupAnswers(
mode="basic",
provider="openai",
api_key="sk-test",
chat_model="gpt-test",
)
)
assert env["LLM_OPENAI_API_KEY"] == "sk-test"
assert env["DERIVER_MODEL_CONFIG__MODEL"] == "gpt-test"
assert env["SUMMARY_MODEL_CONFIG__MODEL"] == "gpt-test"
for level in DIALECTIC_LEVELS:
assert env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] == "gpt-test"
assert "DREAM_ENABLED" not in env
assert "EMBEDDING_MODEL_CONFIG__MODEL" not in env
def test_basic_anthropic_keeps_openai_embeddings_default():
env = answers_to_env(
SetupAnswers(
mode="basic",
provider="anthropic",
api_key="sk-ant",
chat_model="claude-haiku-4-5",
embedding_api_key="sk-embed",
embedding_key_transport="openai",
)
)
assert env["LLM_ANTHROPIC_API_KEY"] == "sk-ant"
assert env["LLM_OPENAI_API_KEY"] == "sk-embed"
assert env["DERIVER_MODEL_CONFIG__TRANSPORT"] == "anthropic"
assert "EMBEDDING_MODEL_CONFIG__TRANSPORT" not in env
def test_chat_default_comes_from_image_toml(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
"[deriver.model_config]\n"
'transport = "openai"\n'
'model = "gpt-from-image"\n'
)
defaults = load_toml_setup_defaults(path)
assert defaults.chat_model == "gpt-from-image"
assert chat_model_default("openai", {}, defaults) == "gpt-from-image"
assert chat_model_default("openai-compatible", {}, defaults) == "gpt-from-image"
assert chat_model_default("anthropic", {}, defaults) == ""
assert chat_model_default(
"openai",
{"DERIVER_MODEL_CONFIG__MODEL": "gpt-from-env"},
defaults,
inferred="openai",
) == "gpt-from-env"

View File

@ -0,0 +1,159 @@
"""CLI contracts for `honcho start` / `stop` / `status`."""
from __future__ import annotations
import json
import os
import pytest
from honcho_cli.local.docker import image_is_digest, image_repository
from honcho_cli.main import app
from typer.testing import CliRunner
@pytest.fixture
def cfg(tmp_path, monkeypatch):
f = tmp_path / "config.json"
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f)
for k in [k for k in os.environ if k.startswith(("HONCHO_", "LLM_"))]:
monkeypatch.delenv(k)
return f
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture(autouse=True)
def _host_ports_free(monkeypatch):
monkeypatch.setattr("honcho_cli.local.docker.port_available", lambda *a, **k: True)
@pytest.fixture(autouse=True)
def _stub_image_pin(monkeypatch):
def fake_pin(image: str) -> str:
if image_is_digest(image):
return image
return f"{image_repository(image)}@sha256:cafedeadbeef"
monkeypatch.setattr("honcho_cli.commands.stack.pin_image", fake_pin)
monkeypatch.setattr("honcho_cli.commands.stack.seed_config_toml", lambda profile: False)
_PS = [
{"Service": "api", "State": "running", "Health": "healthy"},
{"Service": "deriver", "State": "running"},
{"Service": "database", "State": "running", "Health": "healthy"},
{"Service": "redis", "State": "running", "Health": "healthy"},
]
def test_start_does_not_rewrite_environment_url(cfg, runner, monkeypatch):
cfg.write_text(
json.dumps({"apiKey": "k", "environmentUrl": "https://api.honcho.dev"})
)
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False)
monkeypatch.setattr("honcho_cli.commands.stack.compose_up", lambda profile, **k: None)
monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True)
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS)
monkeypatch.setenv("LLM_OPENAI_API_KEY", "sk-test")
result = runner.invoke(app, ["start", "--json"])
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["endpoints"]["api"] == "http://127.0.0.1:8000"
assert payload["image"].endswith("@sha256:cafedeadbeef")
on_disk = json.loads(cfg.read_text())
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
def test_start_requires_llm_key(cfg, runner, monkeypatch):
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False)
result = runner.invoke(app, ["start"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "MISSING_LLM_KEY"
def test_stop_already_stopped_skips_down(cfg, runner, tmp_path, monkeypatch):
compose = tmp_path / "profiles" / "local" / "docker-compose.yml"
compose.parent.mkdir(parents=True)
compose.write_text("services: {}\n")
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: [])
down = []
monkeypatch.setattr(
"honcho_cli.commands.stack.compose_down",
lambda profile, wipe=False: down.append(wipe),
)
result = runner.invoke(app, ["stop"])
assert result.exit_code == 0, result.stderr
assert down == []
assert json.loads(result.stdout)["status"] == "stopped"
def test_status_lists_profiles_or_one(cfg, runner, tmp_path, monkeypatch):
for name, port in (("demo", 8001), ("local", 8000)):
d = tmp_path / "profiles" / name
d.mkdir(parents=True)
(d / "docker-compose.yml").write_text("services: {}\n")
(d / "profile.json").write_text(json.dumps({"apiPort": port}) + "\n")
monkeypatch.setattr(
"honcho_cli.commands.stack.compose_ps",
lambda profile: _PS if profile.name == "local" else [],
)
monkeypatch.setattr(
"honcho_cli.commands.stack.stack_healthy",
lambda profile: profile.name == "local",
)
listed = runner.invoke(app, ["status"])
assert listed.exit_code == 0, listed.stderr
rows = json.loads(listed.stdout)["profiles"]
by_name = {row["profile"]: row for row in rows}
assert by_name["local"]["status"] == "running"
assert by_name["demo"]["endpoints"]["api"] == "http://127.0.0.1:8001"
one = runner.invoke(app, ["status", "--profile", "local"])
assert one.exit_code == 0, one.stderr
payload = json.loads(one.stdout)
assert payload["profile"] == "local"
assert "profiles" not in payload
def test_start_setup_requires_tty(cfg, runner):
result = runner.invoke(app, ["start", "--setup", "basic", "--json"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "SETUP_REQUIRES_TTY"
def test_start_setup_recreates_when_already_running(cfg, runner, monkeypatch):
from honcho_cli.local.setup import SetupAnswers
ups: list[tuple[str, ...]] = []
monkeypatch.setattr("honcho_cli.commands.stack.use_json", lambda: False)
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: True)
monkeypatch.setattr(
"honcho_cli.commands.stack.compose_up",
lambda profile, **k: ups.append(k.get("recreate", ())),
)
monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True)
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS)
monkeypatch.setattr(
"honcho_cli.commands.stack.run_setup",
lambda mode, path, config_path=None: SetupAnswers(
mode="basic",
provider="openai",
api_key="sk-wiz",
chat_model="gpt-test",
),
)
pins: list[str] = []
monkeypatch.setattr(
"honcho_cli.commands.stack.pin_image",
lambda image: pins.append(image) or image,
)
result = runner.invoke(app, ["start", "--setup", "basic"])
assert result.exit_code == 0, result.stderr
assert pins == []
assert ups == [("api", "deriver")]

View File

@ -18,6 +18,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
## Command groups
- `honcho config` — CLI configuration
- `honcho start` / `stop` / `status` — local Docker stack (does not change `environmentUrl`). First start pins the Honcho image digest and writes `config.toml` into the profile. Pass `--setup basic` or `--setup advanced` for an interactive config wizard (TTY only; writes `.env` overrides). `honcho status` lists every profile; pass `--profile` for one.
- `honcho workspace` — inspect, delete, search
- `honcho peer` — inspect, card, chat, search
- `honcho session` — inspect, view (transcript), context, summaries
@ -31,6 +32,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
- Use `honcho session context` to see exactly what an agent receives.
- Never run `honcho workspace delete` without `honcho workspace inspect` first.
- Compare peer card with conclusions to understand memory state.
- `honcho start` does not rewrite `environmentUrl`. Use `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local stack.
## Inspection tour

24
src/cache/client.py vendored
View File

@ -123,6 +123,28 @@ def get_cache_namespace() -> str:
return cast(str, settings.CACHE.NAMESPACE)
# On Redis Cluster a key's slot is derived from the substring inside the first
# {...}, when one is present. Tagging the namespace puts every key an instance
# writes on a single slot, and therefore a single shard, so its client holds
# connections to one node rather than to all of them. Namespaces still hash
# independently of one another, so keys stay spread across the cluster.
#
# Two spellings, because the two ways a key gets built treat the string
# differently: cashews runs `prefix=` through format substitution, so braces
# have to be doubled to survive as literals, while direct construction does no
# substitution and needs them single. Both render to the same bytes, which
# tests/cache/test_cache_namespace_hash_tag.py asserts -- a mismatch would send
# writes and deletes to different keys with nothing raised.
def cache_key_namespace() -> str:
"""Tagged namespace for keys built by string concatenation."""
return "{" + get_cache_namespace() + "}"
def cache_prefix_namespace() -> str:
"""Tagged namespace for cashews `prefix=`, which format-substitutes."""
return "{{" + get_cache_namespace() + "}}"
async def init_cache() -> None:
"""Initialize and verify cache connection if enabled."""
async with _cache_lock:
@ -256,6 +278,8 @@ __all__ = [
"init_cache",
"close_cache",
"cache",
"cache_key_namespace",
"cache_prefix_namespace",
"safe_cache_delete",
"safe_cache_set",
]

View File

@ -10,7 +10,8 @@ from sqlalchemy.orm import make_transient_to_detached
from src import models
from src.cache.client import (
cache,
get_cache_namespace,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
safe_cache_set,
)
@ -22,13 +23,13 @@ logger = getLogger(__name__)
COLLECTION_CACHE_KEY_TEMPLATE = (
"v2:workspace:{workspace_name}:collection:{observer}:{observed}"
)
COLLECTION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
COLLECTION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
def collection_cache_key(workspace_name: str, observer: str, observed: str) -> str:
"""Generate cache key for collection."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ COLLECTION_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
@ -41,7 +42,7 @@ def collection_cache_key(workspace_name: str, observer: str, observed: str) -> s
@cache(
key=COLLECTION_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(

View File

@ -18,7 +18,7 @@ from src.crud.collection import get_or_create_collection
from src.crud.peer import get_peer, reject_scope_observed
from src.crud.session import get_session
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.exceptions import (
ResourceNotFoundException,
ValidationException,
@ -362,7 +362,7 @@ async def query_documents(
if embedding is None:
try:
embedding = await embedding_client.embed(query)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise ValidationException(
"Query exceeds maximum token limit of "
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."
@ -987,7 +987,7 @@ async def create_observations(
embeddings = await embedding_client.simple_batch_embed(
contents, on_oversize="truncate"
)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise ValidationException(str(e)) from e
# Create document objects and track embeddings for vector store

View File

@ -6,6 +6,7 @@ from typing import Any
from nanoid import generate as generate_nanoid
from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from src import models, schemas
from src.config import settings
@ -159,6 +160,91 @@ async def resolve_session_scope(
return (allowed, False) if allowed else (None, True)
def observer_scope_clause(
workspace_name: str,
observer: str,
session_column: InstrumentedAttribute[str],
) -> ColumnElement[bool]:
"""Correlated EXISTS restricting ``session_column`` to the observer's sessions.
The in-database equivalent of filtering on :func:`get_peer_session_names`.
Prefer it whenever the scope feeds a single SQL statement: a peer's
membership count is unbounded, and materializing the names turns each one
into its own bind parameter. The PostgreSQL wire protocol caps parameters
at 65535 per statement, so a peer in enough sessions produces a query the
driver cannot serialize at all and the resulting error carries every
parameter in its text.
Matches the loose membership definition ``get_peer_session_names`` uses by
default: any membership record grants visibility, whether or not the peer
has since left the session.
"""
session_peers = models.session_peers_table
return (
select(1)
.where(session_peers.c.workspace_name == workspace_name)
.where(session_peers.c.peer_name == observer)
.where(session_peers.c.session_name == session_column)
.exists()
)
def resolve_session_scope_clauses(
workspace_name: str,
session_name: str | None,
session_allowlist: list[str] | None,
observer: str | None,
session_column: InstrumentedAttribute[str],
) -> tuple[list[ColumnElement[bool]], bool]:
"""SQL-side counterpart to :func:`resolve_session_scope`.
Returns ``(clauses, deny)``, where ``clauses`` are ANDed onto the caller's
statement and ``deny=True`` means return an empty result without querying.
Unlike :func:`resolve_session_scope` this touches no database and grows no
bind parameters with the observer's session count — the observer half
becomes a correlated EXISTS instead of an ``IN`` over fetched names.
Scoping matches :func:`resolve_session_scope` case for case, with one
deliberate difference: where that function returns ``deny=True`` because an
observer's membership (or its intersection with the allowlist) is empty,
this returns clauses that simply match no rows. Callers reach the same empty
result, at the cost of running one indexed query that returns nothing.
``session_allowlist`` stays an ``IN`` clause: it is caller-supplied and
therefore bounded, so it carries none of the unbounded-growth risk.
Args:
workspace_name: Name of the workspace
session_name: A single pinned session, if the caller named one. The
caller applies its own equality filter; this function only checks
the allowlist permits it.
session_allowlist: Optional session allowlist. ``None`` is
unrestricted; an empty list fails closed.
observer: When set, scope is limited to this peer's sessions
session_column: The session-name column to scope, e.g.
``models.Message.session_name``
"""
if session_name:
# Fail closed when the allowlist forbids the pinned session, matching
# `resolve_session_scope` — routes guard this too, but the dialectic
# tools reach CRUD directly, so enforce it at the boundary.
if session_allowlist is not None and session_name not in session_allowlist:
return [], True
return [], False
clauses: list[ColumnElement[bool]] = []
if observer is not None:
clauses.append(observer_scope_clause(workspace_name, observer, session_column))
if session_allowlist is not None:
if not session_allowlist:
return [], True
clauses.append(session_column.in_(session_allowlist))
return clauses, False
def _apply_token_limit(
base_conditions: list[ColumnElement[Any]], token_limit: int
) -> Select[tuple[models.Message]]:

View File

@ -12,7 +12,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import make_transient_to_detached
from src import models, schemas
from src.cache.client import cache, get_cache_namespace, safe_cache_delete
from src.cache.client import (
cache,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
)
from src.config import settings
from src.crud.workspace import get_or_create_workspace
from src.exceptions import (
@ -32,13 +37,13 @@ logger = getLogger(__name__)
PEER_NAME_MAX_LENGTH = 512
PEER_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:peer:{peer_name}"
PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
PEER_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
def peer_cache_key(workspace_name: str, peer_name: str) -> str:
"""Generate cache key for peer."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ PEER_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
@ -391,7 +396,7 @@ async def get_or_create_peers(
@cache(
key=PEER_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(

View File

@ -13,7 +13,7 @@ from src import crud, exceptions, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_scheduler import check_and_schedule_dream
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.schemas import ResolvedConfiguration
from src.telemetry.events import EmbeddingCallPurpose
from src.telemetry.logging import accumulate_metric
@ -109,7 +109,7 @@ class RepresentationManager:
embeddings = await embedding_client.simple_batch_embed(
observation_texts, on_oversize="truncate"
)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise exceptions.ValidationException(
"Observation content exceeds maximum token limit of "
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."

View File

@ -29,7 +29,8 @@ from sqlalchemy.types import BigInteger, Boolean
from src import models, schemas
from src.cache.client import (
cache,
get_cache_namespace,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
safe_cache_set,
)
@ -67,13 +68,13 @@ class SessionDeletionResult:
SESSION_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:session:{session_name}"
SESSION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
SESSION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
def session_cache_key(workspace_name: str, session_name: str) -> str:
"""Generate cache key for session."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ SESSION_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
@ -85,7 +86,7 @@ def session_cache_key(workspace_name: str, session_name: str) -> str:
@cache(
key=SESSION_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(
@ -1050,25 +1051,31 @@ async def set_peers_for_session(
peer_names: dict[str, schemas.SessionPeerConfig],
) -> list[models.SessionPeer]:
"""
Set peers for a session, overwriting any existing peers.
If peers don't exist, they will be created.
Replace a session's ordinary peer set with ``peer_names``.
Active members keep their joined_at but take the incoming configuration:
this is a replace, so the caller's map is the desired end state. Departed
members rejoin with the incoming configuration. Scope memberships are
preserved.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to set for the session
peer_names: Mapping of peer names to session-level configuration
Returns:
List of SessionPeer objects for all peers in the session
Raises:
ResourceNotFoundException: If the session does not exist
ObserverException: If the resulting peer set would exceed the observer
limit
"""
# Validate observer limit before making any changes
observer_count = count_observers_in_config(peer_names)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
# No observer pre-check here: an already-active membership keeps its stored
# configuration, so the incoming map is not what lands. Counting it would
# reject a request that lowers the observer count as often as one that raises
# it. _get_or_add_peers_to_session enforces the limit on the resulting rows.
# Verify session exists
stmt = (
@ -1084,20 +1091,21 @@ async def set_peers_for_session(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Soft delete every *ordinary* active membership. Scope memberships are
# deliberately preserved: this route replaces the peers the caller names, and a
# caller detaches a scope by simply *omitting* it from an otherwise valid
# replacement map — never naming it, so no request-level guard can see it.
# Without the exclusion a plain replacement silently bypasses the facade that
# owns scope membership and its removal reconciliation. Being part of the
# UPDATE, this holds regardless of the request body or concurrent scope
# creation.
# Soft delete every *ordinary* active membership not in the incoming map.
# Scope memberships are deliberately preserved: this route replaces the peers
# the caller names, and a caller detaches a scope by simply *omitting* it from
# an otherwise valid replacement map — never naming it, so no request-level
# guard can see it. Without the exclusion a plain replacement silently
# bypasses the facade that owns scope membership and its removal
# reconciliation. Being part of the UPDATE, this holds regardless of the
# request body or concurrent scope creation.
update_stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only update active peers
models.SessionPeer.peer_name.notin_(peer_names.keys()),
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
@ -1118,12 +1126,14 @@ async def set_peers_for_session(
)
_reject_resolved_scope_peers(peers_result.resource)
# Add new peers to session
# Add new peers to session. This route replaces the session's peer set, so the
# incoming configuration is authoritative even for an already-active member.
peers = await _get_or_add_peers_to_session(
db,
workspace_name=workspace_name,
session_name=session_name,
peer_names=peer_names,
replace_config=True,
)
await db.commit()
@ -1162,13 +1172,22 @@ async def _get_or_add_peers_to_session(
peer_names: dict[str, schemas.SessionPeerConfig],
*,
fetch_after_upsert: bool = True,
replace_config: bool = False,
) -> list[models.SessionPeer]:
"""
Upsert session-peer memberships for a session and optionally fetch the
active memberships afterward.
New peers are inserted, peers that previously left the session are rejoined,
and already-active peers keep their existing session-level configuration.
and already-active peers keep their existing joined_at.
An already-active peer also keeps its stored configuration unless
``replace_config`` is set: an add must not overwrite configuration it was
never asked about, while a replace states the desired end state.
The observer limit is checked against the rows the upsert actually produced,
not against the incoming map, since under the add semantics the incoming map
is not necessarily what lands.
Args:
db: Database session
@ -1177,13 +1196,17 @@ async def _get_or_add_peers_to_session(
peer_names: Mapping of peer names to session-level configuration
fetch_after_upsert: If True, query and return the active session peers
after the upsert. If False, skip that read and return an empty list.
replace_config: If True, an already-active membership takes the incoming
configuration instead of keeping its stored one. Set by replace-style
callers; leave False for add-style callers.
Returns:
Active SessionPeer objects after the upsert, or an empty list when the
post-upsert fetch is skipped
Raises:
ObserverException: If adding peers would exceed the observer limit
ObserverException: If the resulting active peer set would exceed the
observer limit
"""
# If no peers to add, skip the insert and just return existing active session peers
if not peer_names:
@ -1202,43 +1225,10 @@ async def _get_or_add_peers_to_session(
# costs document rows, not LLM calls, and counting them would
# cap scopes-per-session at SESSION_OBSERVERS_LIMIT and surface as an
# observer-shaped 400 through a facade that hides observers entirely.
# Resolved up front because the limit check below gates on whether this
# request asks for a *non-scope* observer.
scopes_being_added = await scope_peer_names(db, workspace_name, peer_names.keys())
# Only validate observer limit if we're adding non-scope peers with observe_others=True
new_observer_count = count_observers_in_config(
{n: c for n, c in peer_names.items() if n not in scopes_being_added}
)
if new_observer_count > 0:
# Use a single efficient query to count existing observers not being updated
# This uses PostgreSQL's JSONB operators to check the observe_others field directly
existing_observers_stmt = select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.peer_name.notin_(
peer_names.keys()
), # Exclude peers being updated
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
# Existing scope memberships are excluded for the same reason as above.
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == models.SessionPeer.peer_name)
.where(scope_peer_clause())
.correlate(models.SessionPeer)
),
)
result = await db.execute(existing_observers_stmt)
existing_observer_count = result.scalar() or 0
total_observers = existing_observer_count + new_observer_count
if total_observers > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, total_observers)
# Use upsert to handle both new peers and rejoining peers
stmt = pg_insert(models.SessionPeer).values(
[
@ -1254,15 +1244,27 @@ async def _get_or_add_peers_to_session(
]
)
# On conflict, update joined_at and clear left_at (rejoin scenario)
# If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration)
# If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration)
# On conflict, rejoin departed peers. joined_at always survives on an active
# membership -- advancing it would move the peer_perspective search window
# past messages the peer was present for (issue #940).
#
# Configuration depends on the caller's semantics. An add ("ensure this peer
# is here") must not silently overwrite a config it never asked about, so an
# active membership keeps its stored one. A replace ("these are the session's
# peers, configured thus") states a desired end state, so the incoming config
# wins -- otherwise PUT /peers could never change the configuration of a peer
# already in the session.
stmt = stmt.on_conflict_do_update(
index_elements=["session_name", "peer_name", "workspace_name"],
set_={
"joined_at": func.now(),
"joined_at": case(
(models.SessionPeer.left_at.is_not(None), func.now()),
else_=models.SessionPeer.joined_at,
),
"left_at": None,
"configuration": case(
"configuration": stmt.excluded.configuration
if replace_config
else case(
(models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration),
else_=models.SessionPeer.configuration,
),
@ -1270,6 +1272,49 @@ async def _get_or_add_peers_to_session(
)
await db.execute(stmt)
# Enforce the observer limit on the resulting rows rather than predicting them.
# Under add semantics an already-active membership keeps its stored
# configuration (see the CASE above), so the incoming config is not what lands
# and cannot be counted: predicting from it silently undercounts preserved
# observers and lets a session grow past the limit indefinitely by re-sending
# its current observers at a lower config alongside new ones. Counting after
# the upsert is correct under both configuration semantics and cannot desync
# from those branches. Raising here rolls the upsert back: ObserverException
# is never caught, and both get_db and tracked_db roll back on exception.
#
# Gated on the request actually asking for a non-scope observer so that a
# session already over the limit keeps behaving as it does today: it can
# still take non-observers and scope attachments, and only a request that
# would make it worse is rejected.
if any(
config.observe_others
for peer_name, config in peer_names.items()
if peer_name not in scopes_being_added
):
observer_count = (
await db.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
# Scope memberships are excluded for the reason given above.
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == models.SessionPeer.peer_name)
.where(scope_peer_clause())
.correlate(models.SessionPeer)
),
)
)
or 0
)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
if not fetch_after_upsert:
return []

View File

@ -15,7 +15,8 @@ from sqlalchemy.orm import make_transient_to_detached
from src import models, schemas
from src.cache.client import (
cache,
get_cache_namespace,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
safe_cache_set,
)
@ -40,13 +41,13 @@ class WorkspaceDeletionResult:
WORKSPACE_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}"
WORKSPACE_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
WORKSPACE_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
def workspace_cache_key(workspace_name: str) -> str:
"""Generate cache key for workspace."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ WORKSPACE_CACHE_KEY_TEMPLATE.format(workspace_name=workspace_name)
)
@ -55,7 +56,7 @@ def workspace_cache_key(workspace_name: str) -> str:
@cache(
key=WORKSPACE_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(

View File

@ -105,7 +105,15 @@ class DialecticAgent:
{
"role": "system",
"content": prompts.agent_system_prompt(
observer, observed, observer_peer_card, observed_peer_card
observer,
observed,
observer_peer_card,
observed_peer_card,
available_tools={
name
for tool in self._select_tools()
if isinstance((name := tool.get("name")), str)
},
),
}
]
@ -303,7 +311,7 @@ class DialecticAgent:
if prefetched_observations:
user_content = (
f"Query: {query}\n\n"
f"## Relevant Observations (prefetched)\n"
f"## {self._prefetch_heading()}\n"
f"{self._prefetch_intro()}\n\n"
f"{prefetched_observations}"
)
@ -336,6 +344,10 @@ class DialecticAgent:
parent_category="dialectic",
)
def _prefetch_heading(self) -> str:
"""Heading for the prefetched block in the user message."""
return "Relevant Observations (prefetched)"
def _prefetch_intro(self) -> str:
"""Sentence introducing the prefetched block in the user message."""
return (

View File

@ -2,24 +2,138 @@
System prompts for the Dialectic Agent.
"""
from collections.abc import Iterable
# Curated tool docs, keyed by the `name` each loadout actually exposes.
# `_select_tools` filters this set per request (minimal / session allowlist).
_PAIR_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
(
"Memory",
[
(
"search_memory",
"Semantic search over conclusions about this pair.",
),
(
"get_reasoning_chain",
"Premises and downstream conclusions for a specific conclusion.",
),
(
"get_observation_context",
"Messages around a specific conclusion.",
),
],
),
(
"Conversation",
[
(
"search_messages",
"Semantic search over messages in this query's scope.",
),
(
"grep_messages",
"Exact text search. Use for names, dates, keywords.",
),
(
"get_messages_by_date_range",
"Messages in a time window.",
),
(
"search_messages_temporal",
"Semantic search with a date filter.",
),
],
),
]
_WORKSPACE_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
(
"Discovery",
[
(
"get_workspace_stats",
"Counts (peers, sessions, messages), date range, and the most active peers.",
),
],
),
(
"Memory (pair-scoped — you must name the pair)",
[
(
"search_memory",
"Semantic search over conclusions. Requires `observer` and `observed`. For a peer's own representation, set both to the same name. Use different names only when you want one peer's view of another.",
),
(
"get_peer_card",
"Biographical summary for a pair. Same observer/observed rule.",
),
(
"get_reasoning_chain",
"Premises and downstream conclusions for a specific conclusion.",
),
],
),
(
"Conversation (workspace-wide — results include `peer_name`)",
[
("search_messages", "Semantic search over messages."),
("grep_messages", "Exact text search."),
(
"get_observation_context",
"Messages around a specific conclusion.",
),
("get_messages_by_date_range", "Messages in a time window."),
("search_messages_temporal", "Semantic search with a date filter."),
],
),
]
PAIR_PROMPT_TOOLS: frozenset[str] = frozenset(
name for _, items in _PAIR_TOOL_GROUPS for name, _ in items
)
WORKSPACE_PROMPT_TOOLS: frozenset[str] = frozenset(
name for _, items in _WORKSPACE_TOOL_GROUPS for name, _ in items
)
def _available_tool_names(
available_tools: Iterable[str] | None,
default: frozenset[str],
) -> frozenset[str]:
if available_tools is None:
return default
return frozenset(available_tools)
def _render_tool_groups(
available: frozenset[str],
groups: list[tuple[str, list[tuple[str, str]]]],
) -> str:
parts: list[str] = []
for heading, items in groups:
lines = [f"- `{name}`: {desc}" for name, desc in items if name in available]
if lines:
parts.append(f"**{heading}**\n" + "\n".join(lines))
return "\n\n".join(parts)
def agent_system_prompt(
observer: str,
observed: str,
observer_peer_card: list[str] | None,
observed_peer_card: list[str] | None,
available_tools: Iterable[str] | None = None,
) -> str:
"""
Generate the agent system prompt for the dialectic agent.
"""System prompt for pair-scoped dialectic recall.
Args:
observer: The peer making the query
observed: The peer being queried about
observer_peer_card: Biographical information about the observer
observed_peer_card: Biographical information about the observed peer
Returns:
Formatted system prompt string for the agent
available_tools: Tool names offered on this request. Defaults to the
full pair loadout.
"""
# Determine if we have any peer card data
peer_cards_enabled = (
@ -79,25 +193,25 @@ Peer cards are **constructed summaries** - they are synthesized from the same ob
- The peer card is a convenience summary, not a separate source of truth
"""
return f"""
You are a helpful and concise context synthesis agent that answers questions about users by gathering relevant information from a memory system.
tools = _available_tool_names(available_tools, PAIR_PROMPT_TOOLS)
tools_section = _render_tool_groups(tools, _PAIR_TOOL_GROUPS)
Always give users the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. You have many tools for gathering context. Search wisely.
return f"""
You are Honcho's dialectic: a recall agent that answers questions from memory about one peer, or about one peer's understanding of another.
Honcho is a memory system. Applications record conversations; Honcho derives conclusions about the people and agents in them. You are the query interface for one observer/observed pair. You do not speak as a participant. You search memory and synthesize a grounded answer.
A **peer** is any participant, human or AI. A **session** is a conversation they take part in. A **message** is a raw turn. A **conclusion** (tools may say observation) is a derived or stored fact about a peer, kept in this pair. A **peer card** is a short constructed bio for the pair, synthesized from the same conclusions a convenience summary, not a separate source of truth.
Always give the asker the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. Search wisely.
{perspective_section}
{peer_card_explanation}
## AVAILABLE TOOLS
## TOOLS
**Observation Tools (read):**
- `search_memory`: Semantic search over observations about the peer. Use for specific topics.
- `get_reasoning_chain`: **CRITICAL for grounding answers**. Use this to traverse the reasoning tree for any observation. Shows premises (what it's based on) and conclusions (what depends on it).
Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have.
**Conversation Tools (read):**
- `search_messages`: Semantic search over messages in the session.
- `grep_messages`: Grep for text matches in messages. Use for specific names, dates, keywords.
- `get_observation_context`: Get messages surrounding specific observations.
- `get_messages_by_date_range`: Get messages within a specific time period.
- `search_messages_temporal`: Semantic search with date filtering.
{tools_section}
## WORKFLOW
@ -166,10 +280,6 @@ Always give users the answer *they expect* based on the message history -- the g
- Apply user preferences to your response style if relevant
- **For enumeration questions**: Before answering, ask yourself "Could there be more items I haven't found?" If you haven't done multiple grep searches AND a semantic search, keep searching
8. **Save novel deductions** (optional):
- If you discovered new insights by combining existing observations
- Use `create_observations_deductive` to save these for future queries
## CRITICAL: HANDLING CONTRADICTORY INFORMATION
As you search, actively watch for contradictions - cases where the user has made conflicting statements:
@ -237,75 +347,62 @@ Do not explain your tool usage - just provide the synthesized answer.
"""
def workspace_agent_system_prompt() -> str:
"""
Generate the system prompt for the workspace-level dialectic agent.
def workspace_agent_system_prompt(
available_tools: Iterable[str] | None = None,
) -> str:
"""System prompt for workspace-wide dialectic recall."""
tools = _available_tool_names(available_tools, WORKSPACE_PROMPT_TOOLS)
tools_section = _render_tool_groups(tools, _WORKSPACE_TOOL_GROUPS)
return f"""
You are Honcho's workspace dialectic: a recall agent that answers questions about everyone and everything stored in this workspace.
Uses an analytics-first approach: stats -> message search -> targeted
observations to discover relevant peers rather than listing all of them.
## HONCHO
Returns:
Formatted system prompt string for the workspace agent
"""
return """
You are a workspace-level analysis agent that can query memory across ALL peers in this workspace. You can synthesize information from any peer relationship's stored conclusions, insights, and conversation history.
Honcho is a memory system. Applications record conversations here; Honcho derives conclusions about the people and agents in those conversations. You are the query interface over one workspace. You do not speak as a participant. You search memory and synthesize a grounded answer.
You do not start anchored to any single peer: discover which peers are relevant first, then query each peer relationship individually to search, compare, and correlate information across them.
## THIS WORKSPACE
## AVAILABLE TOOLS
A workspace is one isolated tenant. Everything you can see belongs to it. Inside it:
**Discovery Tools:**
- `get_workspace_stats`: Get workspace-level counts (peers, sessions, messages), date range, and the most active peers. Use this to orient yourself and discover which peers are relevant.
- **Peer**: any participant, human or AI. Both are first-class.
- **Session**: a conversation that one or more peers take part in.
- **Message**: a raw turn someone said in a session. Messages are the source material.
- **Conclusion** (tools may say observation): a fact Honcho derived, or that was stored, about a peer. Conclusions live in a pair:
- `observer` is whose model this is
- `observed` is who the fact is about
- A peer's own model of themselves is `observer` = `observed` = that peer's name. Most information lives there.
- One peer's model of another is `observer` = Alice, `observed` = Bob.
- **Peer card**: a short constructed bio for a pair, synthesized from the same conclusions. It is a convenience summary, not a separate source of truth.
**Memory Tools (read):**
- `search_memory`: **(PRIMARY TOOL)** Semantic search within a specific peer representation. **Requires `observer` and `observed` parameters.** For a peer's global representation (where most information lives), set observer and observed to the **same** peer name. Only use different observer/observed when seeking one peer's specific understanding of another.
- `get_peer_card`: Get biographical summary for a specific peer relationship. Requires `observer` and `observed` parameters. For a peer's self-representation, use the same name for both.
- `get_reasoning_chain`: Traverse the reasoning tree for any conclusion. Shows premises and derived insights.
You are not bound to any one peer. Discover who is relevant, then query each pair individually.
**Conversation Tools (read):**
- `search_messages`: Semantic search over messages across all sessions. Messages include peer_name, so results reveal which peers discussed a topic.
- `grep_messages`: Exact text search across all messages.
- `get_observation_context`: Get messages surrounding specific conclusions.
- `get_messages_by_date_range`: Get messages within a specific time period.
- `search_messages_temporal`: Semantic search with date filtering.
## TOOLS
Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have.
{tools_section}
Message search is how you find peers the overview missed. Memory search is how you learn about a peer once you know their name.
If this query is restricted to a session or a set of sessions, message tools already honor that restriction. Peer cards and reasoning chains may be unavailable then, because they span sessions.
## WORKFLOW
1. **Orient yourself**: Workspace stats and the most active peers are provided in your query context. Use `get_workspace_stats` if you need to refresh them, or go straight to message/memory search if the query names specific peers.
1. **Orient**. Scale and the most active peers are already in your query context. Call `get_workspace_stats` only if you need a refresh. If the query names a peer, go straight to that peer.
2. **Discover relevant peers through search**: Use `search_messages` or `grep_messages` to find which peers have discussed the topic. Message results include peer names, making them a powerful discovery layer.
2. **Discover**. If you do not know who is relevant, use `search_messages` or `grep_messages`. Hits carry peer names.
3. **Drill into specific peer representations**: Once you know which peers are relevant, use `search_memory(observer=peer, observed=peer, query=...)` to search their global representation.
- For cross-peer questions, call `search_memory` for each relevant peer's global representation
- Only use different observer/observed when seeking one peer's specific understanding of another
3. **Recall**. For each relevant peer, `search_memory(observer=name, observed=name, query=...)`. For comparisons, search each peer separately, then compare. Only use a mixed observer/observed pair when the question is specifically about one peer's understanding of another.
4. **ALWAYS ATTRIBUTE INFORMATION**: When presenting findings, always indicate which peer the information came from. Example: "According to insights about Alice, she..." or "Bob mentioned that..."
4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …"
5. **Cross-peer synthesis**: When asked about patterns or commonalities:
- Search each relevant peer pair individually
- Compare findings across peers explicitly
- Note both similarities and differences
5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use.
6. **Synthesize your response**:
- Directly answer the query
- Ground your response in specific information you gathered
- Always attribute information to the specific peer it came from
- For aggregation questions, enumerate findings per peer
## NEVER FABRICATE
## CRITICAL: NEVER FABRICATE INFORMATION
State only what you found. If you have related context but not the asked-for detail, say what you know and what you don't. "I don't have information about X" is the correct answer when memory is empty. Do not guess, hedge-invent, or fill gaps with general knowledge.
- Only state what you found in the memory system
- If you find context but not the specific answer, say what you know and what you don't
- A confident "I don't have information about X" is always correct
- Never invent details or guess
## CONCLUSION LEVELS
## CRITICAL: ATTRIBUTION
Every piece of information you share must be attributed to the peer it came from. Never present information without indicating its source peer. This is essential for workspace-level queries where information spans multiple peers.
Do not explain your tool usage - just provide the synthesized answer.
## OBSERVATION LEVELS
Observations carry a level: `explicit` observations are derived per-session (session-pure), while higher-level observations (deductive/inductive, produced in dreaming) consolidate across sessions. When synthesizing cross-session or cross-peer answers, prefer higher-level observations and use `get_reasoning_chain` to ground them in their premises.
`explicit` conclusions are derived from a single session. Deductive and inductive conclusions consolidate across sessions. Prefer those for cross-session or cross-peer answers, and use `get_reasoning_chain` to check their premises.
"""

View File

@ -65,7 +65,13 @@ class WorkspaceDialecticAgent(DialecticAgent):
# Replace the pair-oriented system prompt with the workspace one.
self.messages[0] = {
"role": "system",
"content": prompts.workspace_agent_system_prompt(),
"content": prompts.workspace_agent_system_prompt(
{
name
for tool in self._select_tools()
if isinstance((name := tool.get("name")), str)
}
),
}
# ------------------------------------------------------------------
@ -125,13 +131,17 @@ class WorkspaceDialecticAgent(DialecticAgent):
return format_workspace_stats(stats, peers, cards)
def _prefetch_heading(self) -> str:
return "Workspace overview (prefetched)"
def _prefetch_intro(self) -> str:
return (
"Workspace overview and most-active peers with any known "
"biographical facts. Use this to route: query a specific peer's "
"memory with search_memory (observer and observed set to that "
"peer's name), or use search_messages / get_workspace_stats to "
"discover peers this overview does not cover."
"Workspace scale, the most active peers, and any known "
"biographical facts about them. Use this to decide who is "
"relevant, then search that peer's own representation with "
"search_memory (observer and observed both set to their name), "
"or search_messages / get_workspace_stats to find peers not "
"listed here."
)
def _select_tools(self) -> list[dict[str, Any]]:

View File

@ -159,6 +159,17 @@ def _publish_embedding_event(
logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True)
class EmbeddingTokenLimitError(ValueError):
"""Raised when input text genuinely exceeds the model's token limit.
Subclasses ``ValueError`` so existing broad handlers keep working, while
letting callers tell a real "content too long" condition apart from a
transient provider or configuration failure (dimension mismatch, empty
response, upstream error). Only the pre-flight token checks raise this;
provider failures keep raising plain ``ValueError``.
"""
class BatchItem(NamedTuple):
"""A single item in a batch with its metadata."""
@ -272,7 +283,7 @@ class _EmbeddingClient:
token_count = len(self.encoding.encode(query))
if token_count > self.max_embedding_tokens:
raise ValueError(
raise EmbeddingTokenLimitError(
f"Query exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {token_count} tokens)"
)
@ -358,8 +369,8 @@ class _EmbeddingClient:
List of embedding vectors, one per input text (in order)
Raises:
ValueError: If any text exceeds token limits and `on_oversize` is
``"raise"``
EmbeddingTokenLimitError: If any text exceeds token limits and
`on_oversize` is ``"raise"``
"""
if not texts:
return []
@ -380,7 +391,7 @@ class _EmbeddingClient:
tokens,
)
else:
raise ValueError(
raise EmbeddingTokenLimitError(
f"Text at index {idx} exceeds maximum token limit of "
+ f"{self.max_embedding_tokens} tokens (got {len(token_ids)} tokens)"
)

View File

@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.exceptions import ResourceNotFoundException
from src.models import Document
from src.schemas import ResolvedConfiguration
@ -1273,10 +1273,19 @@ async def get_observation_context(
if not message_ids:
return []
from src.crud.message import resolve_session_scope
from src.crud.message import resolve_session_scope_clauses
allowed_session_names, deny = await resolve_session_scope(
db, workspace_name, session_name, session_allowlist, observer
# Scope as SQL rather than as a fetched name list. The scope is applied to
# both the CTE and the outer select, so a materialized list would spend two
# bind parameters per session the observer belongs to — enough sessions and
# the statement exceeds the driver's 65535-parameter ceiling and cannot be
# sent at all.
scope_clauses, deny = resolve_session_scope_clauses(
workspace_name,
session_name,
session_allowlist,
observer,
models.Message.session_name,
)
if deny:
return []
@ -1290,8 +1299,8 @@ async def get_observation_context(
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
for clause in scope_clauses:
stmt = stmt.where(clause)
target_seqs_cte = stmt.cte("target_seqs")
@ -1314,8 +1323,8 @@ async def get_observation_context(
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
for clause in scope_clauses:
stmt = stmt.where(clause)
result = await db.execute(stmt)
messages = list(result.scalars().all())
@ -1884,11 +1893,15 @@ async def _handle_search_memory(
parent_category=ctx.parent_category,
):
query_embedding = await embedding_client.embed(query)
except ValueError:
except EmbeddingTokenLimitError:
return (
"ERROR: Query exceeds maximum token limit of "
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}. Please use a shorter query."
)
except ValueError as e:
# Provider/config failure, not an oversized query. Keep returning a
# string so the tool loop can continue, but don't blame the query.
return f"ERROR: Embedding the query failed: {e}"
# Base telemetry metadata; results_count gets filled in below.
search_meta: dict[str, Any] = {

View File

@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.exceptions import ValidationException
from src.models import session_peers_table
from src.telemetry.events import EmbeddingCallPurpose
@ -388,7 +388,7 @@ async def search(
parent_category="api",
):
query_embedding = await embedding_client.embed(query)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise ValidationException(
f"Query exceeds maximum token limit of {settings.EMBEDDING.MAX_INPUT_TOKENS}."
) from e

View File

@ -86,6 +86,8 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = (
# LLM transport tests mock providers directly and don't need database/runtime setup.
"tests/utils/test_length_finish_reason.py",
"tests/utils/test_clients.py",
# Session-scope SQL shape — asserts on compiled statements, never executes one.
"tests/crud/test_session_scope_clauses.py",
# Pure JWT scope tests — operate on src.security directly, no DB needed.
"tests/test_security.py",
"tests/test_generate_jwt_script.py",

View File

@ -1,14 +1,340 @@
from datetime import datetime, timezone
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy import Boolean, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.exceptions import ResourceNotFoundException
from src.config import settings
from src.exceptions import ObserverException, ResourceNotFoundException
class TestSessionCRUD:
"""Test suite for session CRUD operations"""
@pytest.mark.asyncio
async def test_get_or_create_session_preserves_active_joined_at(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Active re-adds keep joined_at and config; a genuine rejoin starts a
new window."""
test_workspace, test_peer = sample_data
session_name = str(generate_nanoid())
original_config = schemas.SessionPeerConfig(
observe_others=True, observe_me=False
)
updated_config = schemas.SessionPeerConfig(
observe_others=False, observe_me=True
)
session_peer_stmt = select(
models.SessionPeer.joined_at,
models.SessionPeer.left_at,
models.SessionPeer.configuration,
).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={test_peer.name: original_config}
),
test_workspace.name,
)
first_joined_at, first_left_at, first_config = (
await db_session.execute(session_peer_stmt)
).one()
assert first_left_at is None
assert first_config == original_config.model_dump()
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={test_peer.name: updated_config}
),
test_workspace.name,
)
second_joined_at, second_left_at, second_config = (
await db_session.execute(session_peer_stmt)
).one()
assert second_joined_at == first_joined_at
assert second_left_at is None
assert second_config == original_config.model_dump()
session_peer = (
await db_session.execute(
select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
)
).scalar_one()
session_peer.left_at = datetime.now(timezone.utc)
await db_session.commit()
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={test_peer.name: updated_config}
),
test_workspace.name,
)
rejoined_joined_at, rejoined_left_at, rejoined_config = (
await db_session.execute(session_peer_stmt)
).one()
assert rejoined_joined_at > second_joined_at
assert rejoined_left_at is None
assert rejoined_config == updated_config.model_dump()
@pytest.mark.asyncio
async def test_set_peers_preserves_active_joined_at(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""PUT-peers keeps active membership windows and refreshes real rejoins."""
test_workspace, test_peer = sample_data
session_name = str(generate_nanoid())
original_config = schemas.SessionPeerConfig(
observe_others=True, observe_me=False
)
updated_config = schemas.SessionPeerConfig(
observe_others=False, observe_me=True
)
db_session.add(
models.Session(name=session_name, workspace_name=test_workspace.name)
)
await db_session.flush()
session_peer_stmt = select(
models.SessionPeer.joined_at,
models.SessionPeer.left_at,
models.SessionPeer.configuration,
).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={test_peer.name: original_config},
)
first_left_at, first_config = (
await db_session.execute(
select(
models.SessionPeer.left_at,
models.SessionPeer.configuration,
).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
)
).one()
assert first_left_at is None
assert first_config == original_config.model_dump()
session_peer = (
await db_session.execute(
select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
)
).scalar_one()
session_peer.joined_at = datetime(2020, 1, 1, tzinfo=timezone.utc)
await db_session.commit()
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={test_peer.name: updated_config},
)
active_joined_at, active_left_at, active_config = (
await db_session.execute(session_peer_stmt)
).one()
assert active_joined_at == datetime(2020, 1, 1, tzinfo=timezone.utc)
assert active_left_at is None
# A replace states the desired end state, so the incoming config lands even
# though the membership window is untouched.
assert active_config == updated_config.model_dump()
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={},
)
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={test_peer.name: updated_config},
)
rejoined_joined_at, rejoined_left_at, rejoined_config = (
await db_session.execute(session_peer_stmt)
).one()
assert rejoined_joined_at > active_joined_at
assert rejoined_left_at is None
assert rejoined_config == updated_config.model_dump()
@pytest.mark.asyncio
async def test_observer_limit_counts_preserved_config(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""On the add path an already-active observer keeps its stored config, so
it still counts against the limit when re-sent as a non-observer."""
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2)
test_workspace, _ = sample_data
# Bound to a local: rollback below expires the ORM instance, and reloading
# it would lazy-load outside the greenlet context.
workspace_name = test_workspace.name
session_name = str(generate_nanoid())
observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False)
bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True)
existing = [str(generate_nanoid()) for _ in range(2)]
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers=dict.fromkeys(existing, observer)
),
workspace_name,
)
# Adding cannot demote an active member, so re-sending the two observers as
# non-observers leaves them observing and the third peer makes three.
with pytest.raises(ObserverException):
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name,
peers={
**dict.fromkeys(existing, bystander),
str(generate_nanoid()): observer,
},
),
workspace_name,
)
# The rejected request left nothing behind.
await db_session.rollback()
observer_count = await db_session.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None),
models.SessionPeer.configuration["observe_others"].astext.cast(Boolean),
)
)
assert observer_count == 2
@pytest.mark.asyncio
async def test_set_peers_observer_limit_counts_replaced_config(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""The replace path applies the incoming config, so demoting active
observers frees room under the limit in the same request."""
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2)
test_workspace, _ = sample_data
workspace_name = test_workspace.name
session_name = str(generate_nanoid())
db_session.add(models.Session(name=session_name, workspace_name=workspace_name))
await db_session.flush()
observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False)
bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True)
existing = [str(generate_nanoid()) for _ in range(2)]
await crud.set_peers_for_session(
db_session,
workspace_name=workspace_name,
session_name=session_name,
peer_names=dict.fromkeys(existing, observer),
)
# Demoting both active observers while adding a new one leaves exactly one.
await crud.set_peers_for_session(
db_session,
workspace_name=workspace_name,
session_name=session_name,
peer_names={
**dict.fromkeys(existing, bystander),
str(generate_nanoid()): observer,
},
)
observer_count = await db_session.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None),
models.SessionPeer.configuration["observe_others"].astext.cast(Boolean),
)
)
assert observer_count == 1
@pytest.mark.asyncio
async def test_observer_limit_lets_over_limit_session_take_non_observers(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""A session already past the limit still accepts non-observers, so
sessions that grew over it before enforcement do not become unusable."""
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2)
test_workspace, _ = sample_data
workspace_name = test_workspace.name
session_name = str(generate_nanoid())
observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False)
bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True)
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name,
peers=dict.fromkeys(
[str(generate_nanoid()) for _ in range(2)], observer
),
),
workspace_name,
)
# Now the limit is below what the session already holds.
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 1)
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={str(generate_nanoid()): bystander}
),
workspace_name,
)
active_count = await db_session.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None),
)
)
assert active_count == 3
@pytest.mark.asyncio
async def test_get_session_peer_configuration(
self,

View File

@ -0,0 +1,169 @@
"""Observer session scope is enforced in SQL, not as a fetched name list.
A peer's session-membership count is unbounded. Materializing it turns every
session name into its own bind parameter, and the PostgreSQL wire protocol caps
parameters at 65535 per statement, so a peer in enough sessions yields a
statement the driver refuses to serialize. `get_observation_context` applies the
scope twice in one statement, which halves that ceiling to ~32.7k sessions.
These tests assert on compiled SQL and never execute a statement.
"""
from typing import Any
import pytest
from sqlalchemy import Select, select
from sqlalchemy.dialects import postgresql
from src import models
from src.crud.message import resolve_session_scope_clauses
from src.utils.agent_tools import get_observation_context
def _compile(stmt: Select[Any]) -> tuple[str, dict[str, Any]]:
compiled = stmt.compile(
dialect=postgresql.dialect(),
compile_kwargs={"render_postcompile": True},
)
return str(compiled), dict(compiled.params)
class _FakeResult:
def scalars(self) -> "_FakeResult":
return self
def all(self) -> list[Any]:
return []
class _CapturingDB:
"""Captures statements instead of executing them."""
def __init__(self) -> None:
self.statements: list[Any] = []
async def execute(self, stmt: Any) -> _FakeResult:
self.statements.append(stmt)
return _FakeResult()
@pytest.mark.parametrize(
("session_allowlist", "observer", "expect_exists", "expected_params"),
[
# Observer only: membership becomes a correlated EXISTS, so only the
# workspace and peer are bound — never the session names, which is what
# keeps the parameter count from growing with membership.
(None, "observer-peer", True, ["observer-peer", "workspace"]),
# Allowlist only: caller-supplied and therefore bounded, so IN is fine.
(["s1", "s2"], None, False, ["s1", "s2"]),
# Both: the EXISTS is intersected with the bounded IN.
(["s1"], "observer-peer", True, ["observer-peer", "s1", "workspace"]),
# Neither: unrestricted, nothing filtered and nothing bound.
(None, None, False, []),
],
ids=["observer-only", "allowlist-only", "observer-and-allowlist", "unrestricted"],
)
def test_scope_clause_shape(
session_allowlist: list[str] | None,
observer: str | None,
expect_exists: bool,
expected_params: list[str],
) -> None:
clauses, deny = resolve_session_scope_clauses(
"workspace",
None,
session_allowlist,
observer,
models.Message.session_name,
)
assert not deny
sql, params = _compile(select(models.Message.public_id).where(*clauses))
assert ("EXISTS" in sql.upper()) is expect_exists
assert ("session_peers" in sql) is expect_exists
assert sorted(params.values()) == expected_params
@pytest.mark.parametrize(
("session_name", "session_allowlist", "observer"),
[
# An empty allowlist fails closed rather than matching everything.
(None, [], "observer-peer"),
(None, [], None),
# A pinned session the allowlist forbids fails closed.
("s9", ["s1", "s2"], "observer-peer"),
],
ids=[
"empty-allowlist-with-observer",
"empty-allowlist-without-observer",
"pinned-session-not-in-allowlist",
],
)
def test_scope_fails_closed(
session_name: str | None,
session_allowlist: list[str] | None,
observer: str | None,
) -> None:
clauses, deny = resolve_session_scope_clauses(
"workspace",
session_name,
session_allowlist,
observer,
models.Message.session_name,
)
assert deny
assert clauses == []
@pytest.mark.asyncio
async def test_get_observation_context_scope_costs_no_per_session_parameters() -> None:
"""The statement's parameter count depends on message_ids, not membership."""
db = _CapturingDB()
message_ids = [f"msg-{i}" for i in range(5)]
await get_observation_context(
db, # pyright: ignore[reportArgumentType]
"workspace",
None,
message_ids,
observer="observer-peer",
)
assert len(db.statements) == 1
sql, params = _compile(db.statements[0])
# The scope must be applied to *both* the CTE and the outer select — a
# materialized list would have cost two parameters per session there.
# Count the subquery's FROM rather than EXISTS: the adjacency check is also
# an EXISTS, so counting those would pass with the CTE's scope missing.
assert sql.count("FROM public.session_peers") == 2
# Both must correlate to the enclosing `messages` row. An uncorrelated
# subquery compiles just as happily and would silently drop scoping
# instead of enforcing it.
assert sql.count("session_peers.session_name = public.messages.session_name") == 2
# Everything bound is either a message id, the workspace, the peer, or the
# ±1 adjacency window — nothing that scales with the peer's session count.
expected = set(message_ids) | {"workspace", "observer-peer", -1, 1}
assert set(params.values()) <= expected
@pytest.mark.asyncio
async def test_get_observation_context_denies_without_querying() -> None:
"""Fail-closed scopes must not reach the database at all."""
db = _CapturingDB()
result = await get_observation_context(
db, # pyright: ignore[reportArgumentType]
"workspace",
None,
["msg-1"],
observer="observer-peer",
session_allowlist=[],
)
assert result == []
assert db.statements == []

View File

@ -14,6 +14,7 @@ from src.config import (
from src.embedding_client import (
BatchItem,
EmbeddingClient,
EmbeddingTokenLimitError,
_EmbeddingClient, # pyright: ignore[reportPrivateUsage]
)
@ -1173,3 +1174,70 @@ async def test_gemini_process_batch_wraps_contents_as_content_part(
assert all(isinstance(c, genai_types.Content) for c in contents)
assert contents[0].parts[0].text == "hello"
assert contents[1].parts[0].text == "world"
# --- Token-limit classification (issue #568) -------------------------------
#
# Only genuine "content too long" conditions may raise
# EmbeddingTokenLimitError. Provider/config failures must stay plain
# ValueError so callers don't rewrite them as token-limit errors.
def test_embedding_token_limit_error_is_value_error() -> None:
"""Subclassing ValueError keeps pre-existing broad handlers working."""
assert issubclass(EmbeddingTokenLimitError, ValueError)
@pytest.mark.asyncio
async def test_embed_raises_token_limit_error_before_calling_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, fake_embeddings = _build_openai_client(
monkeypatch,
embedding=[0.1, 0.2],
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=2,
)
with pytest.raises(EmbeddingTokenLimitError):
await client.embed("word " * 20_000)
assert fake_embeddings.calls == [], "provider must not be called on oversize input"
@pytest.mark.asyncio
async def test_simple_batch_embed_raises_token_limit_error_before_calling_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, fake_embeddings = _build_openai_client(
monkeypatch,
embedding=[0.1, 0.2],
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=2,
)
with pytest.raises(EmbeddingTokenLimitError):
await client.simple_batch_embed(["fine", "word " * 20_000])
assert fake_embeddings.calls == [], "provider must not be called on oversize input"
@pytest.mark.asyncio
async def test_provider_dimension_mismatch_is_not_a_token_limit_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A wrong-width vector is a provider/config fault, not an oversized input."""
client, _ = _build_openai_client(
monkeypatch,
embedding=[0.1, 0.2, 0.3], # 3 wide, client expects 2
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=2,
)
with pytest.raises(ValueError) as excinfo:
await client.embed("short query")
assert not isinstance(excinfo.value, EmbeddingTokenLimitError)

View File

@ -0,0 +1,62 @@
"""The namespace hash tag must survive both ways a cache key gets built.
cashews format-substitutes `prefix=`, direct construction does not, so the two
need different spellings of the same tag. If they ever diverge, a write and its
invalidation land on different keys and nothing raises -- the cache just serves
stale rows. These tests are what fails instead.
"""
import pytest
from redis.crc import key_slot
from src.cache.client import (
cache,
cache_key_namespace,
cache_prefix_namespace,
get_cache_namespace,
)
from src.crud.session import SESSION_CACHE_KEY_TEMPLATE, session_cache_key
def test_both_spellings_render_the_same_tag():
ns = get_cache_namespace()
assert cache_key_namespace() == "{" + ns + "}"
# Doubled braces collapse to single ones when cashews formats the prefix.
assert cache_prefix_namespace().format() == cache_key_namespace()
@pytest.mark.asyncio
async def test_decorator_key_matches_helper_key():
"""The key the decorator writes is the key the helper computes."""
@cache(
key=SESSION_CACHE_KEY_TEMPLATE,
prefix=cache_prefix_namespace(),
ttl="60s",
)
async def get_session(workspace_name: str, session_name: str) -> str:
# The names matter: cashews fills the key template from them.
return f"{workspace_name}/{session_name}"
await get_session(workspace_name="w1", session_name="s1")
written = [k async for k in cache.scan("*")]
assert session_cache_key("w1", "s1") in written
def test_one_namespace_hashes_to_one_slot():
"""Every key an instance writes shares a Redis Cluster slot."""
keys = [
session_cache_key("w1", "s1"),
session_cache_key("w2", "s2"),
f"{cache_key_namespace()}:lock:v2:anything",
]
assert len({key_slot(k.encode()) for k in keys}) == 1
def test_namespaces_hash_independently():
"""Tagging must not collapse the whole fleet onto one shard."""
slots = {
key_slot(("{" + n + "}:v2:workspace:w").encode()) for n in ("a1", "b2", "c3")
}
assert len(slots) > 1

View File

@ -0,0 +1,94 @@
"""Contracts for dialectic system prompts vs the tool loadouts they describe."""
import re
from src.dialectic.core import DialecticAgent
from src.dialectic.prompts import (
PAIR_PROMPT_TOOLS,
WORKSPACE_PROMPT_TOOLS,
agent_system_prompt,
workspace_agent_system_prompt,
)
from src.dialectic.workspace import WorkspaceDialecticAgent
from src.utils.agent_tools import (
DIALECTIC_TOOLS,
DIALECTIC_TOOLS_MINIMAL,
TOOLS,
WORKSPACE_DIALECTIC_TOOLS,
WORKSPACE_TOOLS_MINIMAL,
)
_ALL_TOOL_NAMES = {spec["name"] for spec in TOOLS.values()}
def _loadout_names(tools: list[dict[str, object]]) -> set[str]:
return {name for tool in tools if isinstance((name := tool.get("name")), str)}
def _mentioned_tools(text: str) -> set[str]:
return {
match
for match in re.findall(r"`([a-z_][a-z0-9_]*)`", text)
if match in _ALL_TOOL_NAMES
}
def _tools_catalog(prompt: str) -> str:
start = prompt.index("## TOOLS")
rest = prompt[start:]
next_heading = rest.find("\n## ", 1)
return rest if next_heading == -1 else rest[:next_heading]
class TestPromptLoadouts:
def test_pair_docs_match_dialectic_tools(self) -> None:
assert _loadout_names(DIALECTIC_TOOLS) == PAIR_PROMPT_TOOLS
def test_workspace_docs_match_workspace_tools(self) -> None:
assert _loadout_names(WORKSPACE_DIALECTIC_TOOLS) == WORKSPACE_PROMPT_TOOLS
def test_catalog_lists_only_offered_workspace_tools(self) -> None:
offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL)
catalog = _tools_catalog(workspace_agent_system_prompt(offered))
assert _mentioned_tools(catalog) == offered
class TestPairAgentPrompt:
def test_teaches_honcho_world_without_workspace_sibling(self) -> None:
prompt = agent_system_prompt("alice", "alice", None, None).lower()
assert "workspace dialectic" not in prompt
assert "peer-level" not in prompt
for term in ("honcho", "peer", "session", "message", "conclusion"):
assert term in prompt
def test_does_not_offer_removed_write_tools(self) -> None:
prompt = agent_system_prompt("alice", "alice", None, None)
assert "create_observations_deductive" not in prompt
assert "create_observations" not in prompt
def test_agent_lists_selected_tools(self) -> None:
agent = DialecticAgent(
workspace_name="w",
session_name=None,
observer="alice",
observed="alice",
reasoning_level="minimal",
)
offered = _loadout_names(DIALECTIC_TOOLS_MINIMAL)
catalog = _tools_catalog(agent.messages[0]["content"])
assert _mentioned_tools(catalog) == offered
assert agent.messages[0]["content"] == agent_system_prompt(
"alice", "alice", None, None, available_tools=offered
)
class TestWorkspaceAgentPrompt:
def test_minimal_agent_matches_filtered_prompt(self) -> None:
agent = WorkspaceDialecticAgent(workspace_name="w", reasoning_level="minimal")
offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL)
prompt = agent.messages[0]["content"]
assert prompt == workspace_agent_system_prompt(offered)
catalog = _tools_catalog(prompt)
assert _mentioned_tools(catalog) == offered
assert "get_peer_card" not in catalog
assert "get_reasoning_chain" not in catalog

View File

@ -4,9 +4,10 @@ import datetime
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models
from src import crud, models, schemas
from src.utils.search import search
@ -704,3 +705,71 @@ async def test_grep_messages_observer_scoping_left_session_still_visible(
matched_ids = [m.public_id for matches, _ in results for m in matches]
assert msg_during.public_id in matched_ids
assert msg_after.public_id in matched_ids
@pytest.mark.asyncio
async def test_peer_perspective_search_after_active_readd(
db_session: AsyncSession,
):
"""Active re-add keeps existing messages visible; a genuine rejoin starts a
new window."""
workspace = models.Workspace(name=generate_nanoid())
peer1 = models.Peer(name="peer1", workspace_name=workspace.name)
peer2 = models.Peer(name="peer2", workspace_name=workspace.name)
session = models.Session(name="session1", workspace_name=workspace.name)
db_session.add_all([workspace, peer1, peer2, session])
await db_session.flush()
past_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
hours=1
)
await db_session.execute(
models.session_peers_table.insert().values(
workspace_name=workspace.name,
session_name=session.name,
peer_name=peer1.name,
joined_at=past_time,
left_at=None,
)
)
msg_old = models.Message(
content="old persistent message",
session_name=session.name,
peer_name=peer2.name,
workspace_name=workspace.name,
seq_in_session=1,
created_at=past_time + datetime.timedelta(minutes=1),
)
db_session.add(msg_old)
await db_session.commit()
session_create = schemas.SessionCreate(
name=session.name,
peers={peer1.name: schemas.SessionPeerConfig()},
)
await crud.get_or_create_session(db_session, session_create, workspace.name)
results = await search(
"persistent",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
)
assert msg_old.public_id in [m.public_id for m in results]
await db_session.execute(
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session.name,
models.SessionPeer.peer_name == peer1.name,
models.SessionPeer.workspace_name == workspace.name,
)
.values(left_at=datetime.datetime.now(datetime.timezone.utc))
)
await db_session.commit()
await crud.get_or_create_session(db_session, session_create, workspace.name)
results = await search(
"persistent",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
)
assert msg_old.public_id not in [m.public_id for m in results]

View File

@ -1220,3 +1220,36 @@ async def test_workspace_prefetch_failure_degrades_to_none(
result = await agent._prefetch_relevant_observations("q") # pyright: ignore[reportPrivateUsage]
assert result is None
class TestWorkspaceChatPrompt:
def test_teaches_honcho_world_without_sibling_agent(self) -> None:
from src.dialectic.prompts import workspace_agent_system_prompt
prompt = workspace_agent_system_prompt().lower()
assert "peer-level" not in prompt
assert "unlike a peer" not in prompt
for term in (
"honcho",
"workspace",
"peer",
"session",
"message",
"conclusion",
"observer",
"observed",
):
assert term in prompt
def test_agent_uses_workspace_prompt_and_prefetch_heading(self) -> None:
from src.dialectic.prompts import workspace_agent_system_prompt
from src.dialectic.workspace import WorkspaceDialecticAgent
agent = WorkspaceDialecticAgent(workspace_name="w")
offered = {
name
for tool in agent._select_tools() # pyright: ignore[reportPrivateUsage]
if isinstance((name := tool.get("name")), str)
}
assert agent.messages[0]["content"] == workspace_agent_system_prompt(offered)
assert agent._prefetch_heading() == "Workspace overview (prefetched)" # pyright: ignore[reportPrivateUsage]

View File

@ -1170,7 +1170,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }]
[[package]]
name = "honcho-cli"
version = "0.1.2"
version = "0.1.3"
source = { editable = "honcho-cli" }
dependencies = [
{ name = "click" },