Files

49 KiB

AGENTS.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project overview

Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant web (web/) remains Phase H.

Commands

pdm install          # first-time dependency setup
pdm sync             # sync after pulling changes

pdm run ruff check .           # linting
pdm run ruff format .          # apply formatting
pdm run ruff format --check .  # CI: fail if unformatted (do not skip)
pdm run basedpyright .         # type checking (basedpyright, "recommended" strictness)
pdm run pytest                 # tests (pytest-asyncio auto mode)

# Commit gateway (prek — https://prek.j178.dev/): ruff check/format + basedpyright
uv tool install prek           # once
prek install                   # wire git pre-commit hook (once per clone)
prek run --all-files           # run all hooks on demand

GitHub Actions runs ruff check, ruff format --check, basedpyright, then pytest. Local commits run the same checks via prek.toml so ruff format is not forgotten.

Architecture

Conversation units: turn vs round

A round is one model round trip: the request, the single assistant message it produced, and the tool batch that message asked for (one UsageEvent each). A turn is one user message through the explicit stop — the first round whose assistant message asks for no tools — and holds 0..N rounds; retry continues the same turn instead of opening a new one. max_rounds (32 by default) bounds rounds per turn. Turn-scoped state (read-before-edit, yyy auto-continue, /yolo once, todo-nag "touched" flag, last_turn_usage/last_turn_rounds) resets on the next user message; session-scoped state (rows, todo stack, grants) does not. Details: doc/turns-and-rounds.md.

Data modeling: msgspec.Struct

All protocol models use msgspec.Struct — not dataclasses, not Pydantic. Optional fields use msgspec.field(default=UNSET) / default=UNSET with type T | Unset.

typedef.py: Unset = UnsetType (plain assignment so msgspec recognizes it; do not use PEP 695 type Unset = ...). Multi-struct unions must be tagged via tag_field / tag (e.g. role, type) for decode. JSONSchema is dict[str, Any].

Protocol layering (lmproto/)

lmproto/openai_compatible/     ← Base: model, config, client
lmproto/openai/                ← OpenAI platform: Responses + chat (extends base)
lmproto/anthropic/             ← Anthropic Messages: model, config, client
lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
lmproto/deepseek/responses/     ← DeepSeek Responses client (extends OpenAI Responses client; SSE stops at terminal events, no `[DONE]`)
lmproto/deepseek/anthropic/     ← DeepSeek Anthropic client (extends AnthropicClient; no `GET /models`)
  • openai_compatible/model.py — tagged chat messages (SystemChatMessage, UserChatMessage, …), tools, request/response, streaming chunks.
  • openai_compatible/client.py — BaseOpenAIClient / OpenAICompatibleClient via niquests async + SSE; chat_completions, models only.
  • openai_compatible/config.py — OpenAIConfig (token + base URL).
  • openai/model.py — OpenAI Responses API (ResponsesCreateParam, Response, function_call items, stream events).
  • openai/client.py — platform OpenAIClient (kind="responses"): chat completions + responses / get_response / delete_response.
  • anthropic/model.py — Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (thinking, server_tool_use, web_search_tool_result with its verbatim encrypted_content), and tools accepts opaque server-tool dicts. AnthropicRawContentBlock names the same fields on the streaming payloads, and an assistant message's content accepts the opaque block dicts it echoes back.
  • anthropic/client.py — AnthropicClient (kind="messages"): POST /messages + GET /models.
  • DeepSeek — DeepseekOpenAIClient (kind="chat_completions"); models add reasoning_content, prefix, ThinkingOptions, and it injects the thinking flag (enabled, or disabled for reasoning_effort = "none") after encoding, plus an empty reasoning_content on any assistant message that carries none (thinking mode refuses any round — a call or a plain answer — whose reasoning never came back). Config default model ids: deepseek-flash, deepseek-v4-pro. DeepseekResponsesClient (kind="responses") extends the OpenAI Responses client for convention = "responses" (SSE stops at terminal events — DeepSeek sends no [DONE]). DeepseekAnthropicClient (kind="messages") extends AnthropicClient for convention = "anthropic" (base https://api.deepseek.com/anthropic; models() returns []).

Config (config/)

TOML load/store (ConfigStore): [providers] tagged union presets, [database] section. Default path via platformdirs. Providers may set timeout as a float or { connect, read } (HttpTimeoutConfig); omitted → product defaults (10s connect / 600s read). DeepseekProvider.convention (Literal: ""/"openai"/"anthropic"/"responses") selects the DeepSeek API surface — "responses" → OpenAI Responses, "anthropic" → Anthropic Messages on https://api.deepseek.com/anthropic (default url follows the convention); a per-model ModelConfig.convention overrides it (empty = inherit). [mcp] (McpConfig): servers.<name> stdio server definitions (McpServerConfig: command/args/env/cwd/timeout/read_only, url reserved for streamable HTTP) + disable gate; defining a server opts it in. [skills] (SkillsConfig): paths (explicit roots first), discover (plyngent user dir + claude dirs) with include_project_roots, enabled, and allow_write (standing write grant); a malformed section degrades to defaults like [agent] / [mcp]. [trash] (TrashConfig): enabled — whether delete_path moves to the system trash or removes the path outright; a typo degrades to the default (trash on).

reasoning.py: thinking strength as reasoning_effort (ReasoningEffortConfig: "" = send nothing | none … high … max) and thinking_budget_tokens, settable on [agent], a provider, or a model entry — resolve_reasoning(agent, provider, model) merges the layers per field (model > provider > agent) and clamps max (DeepSeek chat only) to xhigh for every other surface, returning a ReasoningConfig the agent loop and create_client consume.

Runtime (runtime/)

create_client(provider, *, model=None, reasoning=None) maps config Provider → protocol client by effective preset (model-level preset/url overrides apply); reasoning (resolved ReasoningConfig) carries the Anthropic thinking_budget_tokens onto AnthropicConfig — the other surfaces read the effort off the request param — and is derived from the provider/model layers alone when omitted. normalize_http_timeout feeds OpenAI-compatible configs and Anthropic. Mapping: openai → OpenAIClient (agent uses Responses), openai-compatible / deepseek → chat-completions clients (deepseek with effective convention = "responses" → DeepseekResponsesClient, "anthropic" → DeepseekAnthropicClient), anthropic → AnthropicClient.

MCP (runtime/mcp_client.py): McpManager owns one McpServerConnection per [mcp] server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, tools/list (cursor pagination), tools/call, notifications/tools/list_changed → tools_stale. Spawn/handshake failures record connection.error (status string), never raise; ensure_started / restart(config) / aclose / refresh_tools; statuses() → (server, status, tool_count) rows; instructions() → (server, text) for every connected server that returned optional InitializeResult.instructions (usage guidance captured per connection); per-request timeout from config (default 30s).

Skills (skills/)

Discovery and parsing, independent of the agent: build_roots(config, workspace=…, user_config_dir=…, home=…) resolves [skills] into ordered SkillRoots (config explicit paths → plyngent user dir → claude user/project dirs), SkillStore scans each root for directories with a SKILL.md (a root may itself be one) and parses the frontmatter subset (frontmatter.py) on every access — nothing is cached, so an edit or a new skill made outside the chat is simply there. skills returns the visible records sorted by name (first root wins), shadowed() the losing copies, find(name) exact-then-case-insensitive, default_write_root() our own directory (or the first root); resolve_inside / resolve_skill_file keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry issues (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.

Memory (memory/)

Async SQLAlchemy + aiosqlite. MemoryStore: schema init (+ lightweight SQLite ALTER for new columns; PRAGMA user_version 4), default local user, sessions (bound to workspace path; optional provider_name/model; todo_stack + access_grants JSON columns), messages stored as msgspec chat message JSON.

Agent (agent/)

  • Kind-based dispatch: clients expose kind (chat_completions | responses | messages). Agent history stays chat-completions-shaped; transport adapters return synthetic ChatCompletionResponse / stream chunks.
  • responses_bridge / responses_dispatch: chat ↔ OpenAI Responses; stream text/reasoning deltas (response.reasoning_text.done repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on response.completed; a request carrying tools returns every round's reasoning as a reasoning item of reasoning_text parts — "必须完整回传 reasoning_content … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's function_call items stay contiguous ahead of their outputs (call, output, call, output was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's content parts.
  • messages_bridge / messages_dispatch: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; thinking from the request's reasoning_effort (level → token budget, floor 1024, max_tokens grown when the budget would leave no room) plus the client's configured thinking_budget_tokens; a thinking block the model refuses (from Claude 4.7 on, thinking.type.enabled is a 400 asking for adaptive thinking + output_config.effort; a model whose thinking is always on refuses thinking.type.disabled) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (server_tool_use + its web_search_tool_result) is the surface's own business — it streams input_json_delta fragments like a call but nothing local asked for it, so only a tool_use block becomes a tool call and nothing is executed; thinking and the two search blocks are kept as AssistantChatMessage.provider_blocks (the thinking text also lands in reasoning_content, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (compat.coerce_chat_messages).
  • Images (see doc/images.md): a user turn's content may be a part array (ChatTextPart + ChatImagePart), and a tool result carries its pictures in ToolChatMessage.images; each surface lifts them into what it can express — chat-completions content blocks (tool-result images move into the user turn after the tool run, since a tool message holds text only), Anthropic image blocks (inside tool_result for a tool result), Responses input_image parts (in a function_call_output item). Pictures belong to user turns: an image in a system or assistant message is a provider 400. agent/images.py sniffs the format from the bytes and caps an inline picture at 8 MiB, because every round re-sends it; [providers.*.models.*].image_in is the opt-in, and every image-carrying path checks it — an @path mention, view_image, fetch_local's image body, and (as a safety net in the registry) an MCP server's or plugin's ToolOutput.
  • Provider-side tools: provider_tools on the OpenAI and DeepSeek presets (list of dicts; [{type="web_search"}] when omitted; [] disables) threaded via ChatAgent/run_chat_loop into Responses and Messages; never executed by ToolRegistry. The Messages surface declares its own server tool instead — a bare web_search becomes {type="web_search_20250305", name="web_search"} (the search then runs inside the response, and its encrypted_content results go back verbatim with the round — AssistantChatMessage.provider_blocks) — and a DeepSeek provider on a surface that ignores hosted tools (config.routing.local_web_search) gets the local web_search tool from tools/net/search.py instead.
  • @tool / ToolRegistry: decorator infers JSON Schema from type hints; execute tools by name. A handler returns its text, or a ToolOutput(text, images) when it also produced a picture: execute keeps the text-only contract and execute_output returns the pair, which run_chat_loop stores as the tool message's text plus its images (view_image, an image body from fetch_local, and an MCP image content block all take that path). Before running, execute answers a call that omits a required argument with the accepted names (_missing_required_error) — the handler's bare TypeError never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a present argument are the tool's own job (see the chat bullet), unless the tool declares its own pre-prompt check (@tool(validate=…), answered in that same place). run_argv / run_argv_batch / open_pty / write_pty_keys / edit_replace / edit_lineno / fetch_local do: a timeout_seconds that is not a positive number (so a value that would only fail inside wait_for never spawns a process), an argv sent as a string, an unknown <key> marker, an empty old_string or max_replaces < 1, a backwards line range, a verb outside GET/POST/PUT/DELETE and a URL that cannot be parsed. prepare_batch_confirm leaves such a call out of the round's prompt, so nobody is asked about a call that cannot run. @tool(writes=("path",)) names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
  • run_chat_loop: multi-round tool loop; default streaming text deltas + stream tool-call merge; parallel tools; request-prep tool-result cap (each result stored whole, trimmed on the request copy with a document_id=... marker); soft context compact on request (API-calibrated after first usage when available); cooperative cancel points; optional on_limit; resolved reasoning_effort on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in messages, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to max_truncation_continues (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for /retry.
  • ChatAgent: optional MemoryStore (user message persisted immediately; completed tool batches checkpointed mid-turn; unfinished assistant suffix rolled back on failure); stream; system prompt; provider_tools; retry() continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and pending_retry_text reads a truncation notice as unfinished work.
  • /compact: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → new session seeded with summary message. Soft-compact keeps the last 12 tool results full-size by default (DEFAULT_RECENT_TOOL_RESULTS); older tool payloads shrink first. The default summarizer system prompt states that tool calls are unavailable (a compact_system_prompt override replaces that default wholesale).
  • Events: text_delta, reasoning_delta, assistant_message, tool_call/result, max_rounds, truncated (kept_chars/continued), error (retryable/source), cancelled (reason), usage (TokenUsage).
  • Usage: API usage from completions (stream with include_usage); char≈token fallback (~4 chars/token) when omitted; context size = last request prompt_tokens (API preferred); last_turn_usage / session_usage are billed sums (tool rounds re-send history); CLI end-of-turn + /status.
  • Config [agent]: system_prompt (persona; default DEFAULT_SYSTEM_PROMPT; "" omits persona), tool_directives (tool playbook; default DEFAULT_TOOL_DIRECTIVES; "" omits playbook; both empty → no system), mcp_instructions (fold connected MCP servers' initialize instructions into the system prompt when tools on; default true), allow_paths (static out-of-workspace pre-allow path → read|write|exec; never prompts), auto_continue_limits (auto-raise tool/PTY limits without prompting; default false), reasoning_effort / thinking_budget_tokens (thinking strength; see Config), max_tool_result_chars (wire cap; the request-prep pass trims a longer tool result to its head plus a document_id=... marker), hard_max_tool_result_chars (default 2,000,000: storage ceiling for one result kept whole in its message; a result above it keeps its head behind a leading [Stored at the …-char ceiling: …] notice and its document cannot serve the dropped tail), parallel_tools, confirm_destructive, path_denylist, max_context_tokens (default 200k est. tokens).

Tools (tools/)

Module-level @tool handlers. Call set_workspace_root() before use.

  • workspace: path resolve under primary root or temporary allowlist roots or a directory-access grant (mode-aware: read < write < exec, default required write; a file grant matches only that file); path substring denylist (wins over grants); command denylist via match_command_denylist (checks every program of the wrapper/run chain, resolved by tools/command_scan.py; an entry is a program, plus optional argument words that make it dangerous — systemctl reboot poweroff … matches systemctl reboot and systemctl start reboot.target, not systemctl status; basenames are lower-cased, so RM / rm.exe count; defaults cover privilege sudo/su/doas/pkexec/run0/systemd-run, destructive rm/mkfs/dd/chown/mount/…, and power state shutdown/reboot/poweroff/halt/init/telinit/kexec/pm-*/s2*; CLI: timed human allow override, default deny on timeout; not skipped by YOLO); escape error names the request_directory_access mode the call needs (call request_directory_access with mode='write').
  • access (tools/access.py): request_directory_access (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one path → mode pair in one of three stores — WorkspacePolicy.config_allow (static [agent].allow_paths), SessionState.access_grants (human-approved, persisted on the session row), WorkspacePolicy.yolo_allow (process only: an answer a host keeps without persisting — YOLO/--yes never fills it, an out-of-workspace grant is asked either way). AccessDecision(mode, persist) from the hook; denylisted paths can never be granted; cap MAX_ACCESS_GRANTS. Not a sandbox: exec grants (and any command) reach the whole FS.
  • file: read_file (with_lineno, max_chars default 32k; results start with a 1-based L{begin}-{end} range; an image file is refused with a pointer to view_image), view_image (a picture reaches the model as an image part; format sniffed from the bytes, detail = auto default / low / high / original), write_file (refuses to replace an existing file unless overwrite=true; that replacement is the one soft-confirmed), listdir, tree (model picks format=markdown default / flat paths / decorated; VCS + default noise dirs + optional skip_dirs / denylist walk), glob_paths, regex_files (Python re per line, not a glob; literal file/dir path, no glob expansion; skips VCS/binary), edit_replace (max_replaces, reports remaining matches), edit_lineno (1-based range; only lines read via with_lineno; read state resets on any file write/edit/copy/move/delete), copy_path / move_path / delete_path (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; permanent=true, [trash] enabled = false, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; doc/trash.md), new_temporary_workspace (system temp allowlist; cleanup on chat exit; no session rebind), get_truncated (resume a truncated read_file slice or @path mention via its truncate_token=...), read_document / search_document (read a capped tool result by its document_id: a line window with optional line numbers, or a per-line regex), document_info / list_documents (describe or list the session's documents).
  • process: run_argv (argv, no shell, timeout, optional stdin/env); run_argv_batch (a node is argv plus then / else / pipe continuations — one node or a list each — with cwd / env / stdin / mix_stderr / stop_on_error / timeout_seconds; a continuation inherits its parent's cwd / env, a pipe chain runs to its last command and only that exit code decides the branch, max 20 nodes and 4 levels, and a pipe step takes no then / else); PTY open_pty / read_pty / write_pty (literal) / write_pty_keys (key markers) / ask_into_pty (human→PTY only; secret no-echo; answer never in tool result) / close_pty (POSIX: pty+fork; Windows: ConPTY via pywinpty env marker dep).
  • PTY: backend in pty_backend.py; structured status (alive/exit_code/data); read_pty(..., until=) (CSI sanitized so host TTY is never reset); keys via write_pty_keys only (<c-c>, <esc>, <c-up>, <c-s-f5>, <up*3>, <m-x>, \xHH — xterm modifier sequences where they exist; an unknown <…> is an error, never typed); secrets via ask_into_pty (not write_pty data); session limit/idle TTL/output budget; close terminate→kill.
  • CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts y/empty (once), n, or yyy = stop asking for the rest of the turn (cli.limits module flag, reset by run_turn_with_retries, /btw, session switches, and chat exit). --auto-continue / [agent] auto_continue_limits set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
  • Destructive confirms: classify_danger + ToolRegistry(on_confirm=…); CLI default deny; config confirm_destructive / path_denylist. Soft confirm also for interpreters — bare shells/REPLs, scripts, and -c one-liners alike (-c is code only for an interpreter, so grep -c / od -c stay unflagged; argv + code preview) —, detached runners (nohup), and wrapper options that carry a command line (env -S "…", su -c "…", whose text is printed under its own env -S: label), each resolved past env wrappers and <launcher> run — every wrapper's options are spelled as a getopt spec (command_scan._WRAPPERS: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (options not understood … the wrapped program is a guess) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. ssh / mosh are unwrapped like a wrapper (command_scan): the program reviewed is the one the client runs on the far end (ssh prod rm -rf / is refused as rm, ssh prod bash -c … prints the remote code, an escalation behind the hop keeps yolo_skip=False), and -o BatchMode=yes always asks because nothing on either end can prompt. A client with no command only opens a remote shell: nothing typed or piped into it (write_pty, run_argv(stdin=…)) is checked, so opening one is announced and confirmed instead — remote shell on 'prod' (ssh with no command; its input is not checked) — while the client itself stays outside the denylist (there is no program to name). The delete_path confirm names what will happen — move path 'x' to the system trash, or delete path 'x' permanently when the call asks for it, the host cleared [trash], or the path is scratch — and the pretty line prints (to trash) for a delete that landed there. Session YOLO: /yolo on|off|once and --yes (skip the soft confirms of YOLO-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through DangerReason(yolo_skip=False); hard denylists unchanged and a directory-access request is asked either way; once expires after the next user turn). A round of tool calls asks once: ToolRegistry.prepare_batch_confirm pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one ToolBatchConfirmHook prompt whose BatchConfirmDecision execute(..., decision=…) applies to each call (CLI box lists every call: y = allow these, m = review them one at a time through the per-call prompt, N = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt. Every allow says so in the result itself: the tool text leads with [approval] user (once), user (round) (batch), grant (session) (a TRUSTABLE grant, so nobody was asked this time) or yolo (auto), and a handler that prompts on its own records it with tools.context.record_approval — workspace.check_command_allowed (user/grant (process)), tools/net/grants.py (user/grant (process)) and the skill write path (user (once|session), grant (session), config (policy)). One line per call (Approval + most_specific_approval: the answer closest to a human wins), none for a call that needed no confirmation or ended in error:, and it leads so a wire-capped result and read_document keep it. A refusal names which no it was (Denial / DenialKind): user declined (with the comment), no answer within 30s — denied by default (the prompt's deadline), prompt cancelled (Ctrl+C / EOF), or no confirm hook or non-interactive (denied) — a timeout is never reported as a decision, and every timed prompt (workspace.check_command_allowed, tools/net/grants.py, tools/skills.py, tools/access.py) carries the reason it got into the tool message.
  • vcs: read-only VCS tools (vcs_kind / vcs_status / vcs_diff / vcs_log / vcs_branch) via VcsBackend protocol; git implemented; detectors are pluggable for other systems.
  • chat: human prompts as tools — ask_user_line / ask_user_choice / ask_user_form (shared prompting core); wait (line prompt with timeout; Enter disturbs). Container args are shape-checked (tools/chat/shape.py): a mis-typed string options/fields (or a non-string question) returns an explained error — options="a, b" used to iterate characters into a per-character menu. wait's duration accepts numeric strings ("5" = 5s) and rejects booleans/text/containers.
  • todo: LIFO stack of task groups (not a queue of tasks) — todo_push creates one group of siblings; todo_pop removes the whole top group; todo_update by the number a listing shows (render order, TOP group first; the stored id stays internal); DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work ([TODO OPEN WORK]); all-terminal non-empty = hygiene ([TODO HYGIENE]); nag channel via [agent] todo_nag_strategy (developer/user = prose nag; synthetic_tool = forged todo_list call + real stack.render() result + ToolCall/Result events; none).
  • net / fetch_local and fetch: fetch_local is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (asyncio getaddrinfo). Private/loopback hosts need instance policy grant (CLI timed confirm; not YOLO). Public cleartext HTTP and mutating methods soft-confirm (YOLO/TRUSTABLE). HTTPS→HTTP redirects blocked unless allow_http_downgrade. user_agent tool arg (or headers) is never replaced by the default when provided. Caps: body bytes, request body size. fetch is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: POST /v1/tools/fetch (lmproto/deepseek/webtools/) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does not run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to fetch_local on its own. Its body is stored whole in the tool message, so a trimmed document_id reads the rest with no new request and no new charge. Both are READ_ONLY; fetch_local enforces GET-only in a read-only context.
  • net / web_search (tools/net/search.py, tools/net/bing.py): the search a DeepSeek provider cannot host, as a local tool. build_web_search_tool(backends) closes over the sources named by provider.search_sources and asks them in fallback order (search_in_order): DeepseekSearchBackend (one POST /v1/tools/search through lmproto/deepseek/webtools/, no model round — deepseek_api_base_url drops the convention's /anthropic and any /v1 before the client appends its path; 408/429/5xx are retried 0.5s/1.0s, 402 and other 4xx are not) and/or BingSearchBackend (Bing's RSS results, snippets included, fetched through the same client as fetch_local; opt in with search_sources = ["deepseek", "bing"] — the default is ["deepseek"]). Hits are SearchHit from lmproto/search.py (DeepSeek's snippet, else its summary cut to 200 chars); the handler returns one title — url entry per hit (plus the snippet when the source has one), and a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when config.routing.local_web_search says so: a DeepSeek provider with a non-empty search_sources, unless the Anthropic convention already hosts the search (convention = "anthropic" and provider_tools ask for web_search) — so provider_tools = [] brings the local tool back on that surface too. provider_tools owns the hosted tool, search_sources owns this one. Tags LOCAL|READ_ONLY.
  • mcp (tools/mcp.py): register one server's advertised tools into the catalog as namespaced mcp__<server>__<tool> definitions with ToolSource(kind="mcp", plugin_id=server); tags LOCAL (+ READ_ONLY when the server config sets read_only) — never YOLO/TRUSTABLE. register_mcp_tools purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by McpManager.
  • skills (tools/skills.py): discovery, reading, and writing of skill directories (plyngent.skills owns roots, the SKILL.md frontmatter subset, the uncached store, and the inside-the-skill path rule). skill_list (names, source root, directory, description, shadowed copies, frontmatter notes), skill_read (a SKILL.md by name, or one bundled file — reuses read_file for ranges/line numbers/truncate_token), skill_search (regex over every visible skill via regex_files, hits rebased on <skill>/<file>), skill_create (new SKILL.md + optional files), skill_edit (replace / append / rewrite one file; a rewritten SKILL.md is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as WorkspacePolicy.static_read, so read_file/regex_files see them while writes stay denied); writes go through SkillWritePolicy on the instance — denylist first, then SessionState.skill_write_roots (a session answer), then [skills].allow_write, then the host's confirm hook (CLI prompt_skill_write_confirm: once/session/no, timed, default deny, not skipped by YOLO); a host with no hook denies. Grants are per skill directory; [skills].enabled = false drops the tools from the registry (SKILL_TOOL_NAMES).
  • DEFAULT_TOOLS / catalog: access + file + process + vcs + chat + todo + skills + net (fetch_local) for local surface. ToolTag.READ_ONLY marks non-mutating tools (read_file, view_image, get_truncated, read_document/search_document/document_info/list_documents, listdir, glob_paths, regex_files, tree, todo_list, wait, ask_user_*, vcs_*, skill_list/skill_read/skill_search, fetch, fetch_local); ToolRegistry.clone(read_only_only=True) selects just those for safe side turns / planning, and read_only_context() (in tools/context.py) makes fetch_local accept GET only.

Prompting (prompting.py)

Shared interactive I/O: ask / choose / form / confirm with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (run_prompt_async).

Three process-wide hooks let a host take over a prompt: set_prompt_output_hook (break an open pretty line before the prompt draws), configure_prompting(pause_factory=…) (the SIGINT guard around an off-loop read), and set_prompt_wait_hook — a prompt that blocks announces itself through prompt_wait(kind, title, detail, choices, *, body, facts, expires_in): the short title, the body it asks (a question, whole), the gated detail (a reason that can name a command or a URL), facts (the object — a program or file name, never an argument), and the expires_in after which its default answer takes over. It yields the PromptAnswerSource the read may consult (take() returns allow / deny / reply text, or any key the prompt named in choices, without blocking) or None for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. A source that will offer an answer says so through supports_actions before it offers it, so the read polls from its first moment instead of deciding once. read_line_with_timeout accepts a source and a None deadline, and stdin always wins over a host answer. The CLI's implementation is cli/notify.py ([notify], desktop via the optional desktop-notifier extra, terminal via OSC 777/9 to /dev/tty): a prompt is announced only after grace_seconds — except one that follows an answer given on the notification, which goes out at once and past the burst throttle — choices become the notification's buttons, the answer is treated like a typed one (never a session grant), the notification is sent without expiry (timeout=0, urgency at the library default) so it stays on screen until it is dismissed — a prompt with a deadline gives it that deadline instead — and every failure falls through to the next channel instead of raising. A notification that went to the desktop rings the terminal's bell: a popup may be on another virtual desktop, and no desktop lets a program raise another window. The body is filled in reading order (question, reason, object) until the budget is gone, with the deadline and the workspace — the directory the chat runs in, ~ for home — kept for last, so a long reason drops out before they do; the desktop keeps the lines apart and the terminal flattens them. A popup is sent with no expiry, except for a prompt that gives up on its own: its deadline becomes the popup's timeout. There is no "go to terminal" button — no desktop lets a program raise another window, and the popup's own close does as much; instead every notification that went to the desktop also rings the terminal's bell, and a click on the body (the platform's default action) or a close just withdraws it, leaving the prompt waiting. A secret prompt is never announced.

CLI (cli/)

Click app + readline REPL. Entry: plyngent / python -m plyngent.

  • plyngent chat: provider/model (flags or interactive; Tab via readline in prompting); sessions store provider_name/model and restore on resume; SQLite via [database] (file DB under user data when url unset; explicit url = ":memory:" kept + warn); workspace-bound; resume latest for cwd/--workspace by default (--new / --session). One-shot: -p/--prompt and non-TTY stdin; exit codes 0/1/2/3; --yes (YOLO on), --auto-continue (auto-raise limits), --stream/--no-stream, --quiet. Root --log-level. Enabled [mcp] servers start before the REPL (_start_mcp_manager, per-server failures warn on stderr) and are closed on exit (McpManager.aclose); each server spawns in its own session/process group (start_new_session / CREATE_NEW_PROCESS_GROUP), so a terminal Ctrl+C does not kill it.
  • Slash: Click group in cli/slash.py + awaitlet for async work; Tab completer from registry + ParamType shell_complete. /history is turn-oriented (cli/transcript.py): a turn runs from a user row to the next one, rows carry display ordinals (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full, -v expands every row (one-liners), -vv prints full bodies + local rows, --preview collapses every row, and N/last [N] address turns while --message N addresses one row. Multiline """ … """; /edit via $VISUAL/$EDITOR (blocking only). An @path mention in the typed line attaches that file to the turn (cli/mentions.py): an image becomes an image part (skipped with a note when the model has no image_in), any other file is inlined under a --- @path --- header, capped like a tool result with a resumable truncate_token; @"path with spaces" is the quoted form, and Tab completes the path (quoted when it holds a space, directories end in /). /yolo on|off|once for soft destructive confirms. /model --persist / /models --persist write model catalog entries into TOML. /todos for human show/push/pop/clear of the todo stack. /skills (list default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; /skills search PATTERN runs the matching tool handler (the slash path binds the tool context itself); /skills open NAME reveals the skill's directory in the system file manager. /mcp (list default) prints per-server status/tool counts + an instructions preview when the server sent usage guidance, + last stderr lines; /mcp reconnect re-reads config, restarts every enabled server, and rebuilds the tool registry. /grants lists directory-access grants (revoke <index|all>; config grants point at the TOML); /status includes the grant count and the visible skill count (or off when [skills].enabled is false).
  • /btw side questions: forked session; main transcript/DB untouched. --tools=read (default) exposes only READ_ONLY tools under read_only_context() (fetch=GET) with the main session view; --tools=no off; --tools=full whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit read/full errors. The model gets an aside notice as its last message before the question (unsaved exchange + tool scope), shown to the user as [notice] aside: ….
  • Explicit /resume or --session from another workspace prompts: keep / update / abort. A session resumed by a freshly started process (--session / default latest) also gets a resume notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
  • Host notices (agent/notices.py): system-level messages appended as trailing developer rows, never system-prompt edits — Notice.to_message() for the model, .summary for the user via cli.display.echo_notice (--quiet hides the user line only). Durable hosts persist them (ChatAgent.push_notice); a side agent without memory keeps its notice local. has_notice() dedupes a session-scoped notice: the skills one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with skill_list / skill_search. Trailing developer messages never end a turn, so /retry still sees an interrupted turn behind a notice or checkpoint.
  • Failed/cancelled turns: user kept; committed tool rounds kept, and a batch cut short keeps the calls that already produced a result (rollback_tail; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on /retry, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a truncated notice (see the run_chat_loop bullet), which also keeps the turn unfinished for /retry; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, cli/interrupt.py — a stray Ctrl+C while the loop waits never tears the REPL down; the handler is rebound to whichever loop owns the session, since asyncio drops it with its loop); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (wait reports cancelled by user) or is ignored with a hint for readline prompts (ask_user_*, confirms) — never the turn, never the loop (a slash command's own work is a task too — /compact cancels its summary request, /btw its side turn — and handle_slash answers that CancelledError with one cancelled line, so the REPL survives; before this, nothing was registered as a target during a slash command and the router dropped the signal); auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget resets when an attempt completes a model round (connection recovered); then /retry.
  • Pretty tool lines: every builtin tool prints its * Verb 'target' prefix as the call starts and appends the outcome (L1-4 (done), (exit code 0), (session 3)) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (* MCP <server>:<tool> (done)). Plugin tools and custom tool calls keep the [tool] … / [tool ok] … whole-line style. Interactive TTY only (interactive_terminal()): piped/plain output and --verbose keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs ends the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one * Todo x<N>: Push, Update line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. run_argv_batch lists its commands above its row: one $ command line per node, a pipe chain joined into one line with |, a then / else body indented (two spaces per level) under the node it follows, each line elided past 80 characters.
  • plyngent providers, config path|edit. Config open: $VISUAL/$EDITOR (wait), else system default (xdg-open / open / startfile, non-blocking). /edit stays blocking-only. No providers → optional edit then reload when waited.
  • Tools default on; workspace defaults to cwd; --max-rounds default 32. Readline history under platformdirs (repl_history). PTY: close_all on chat exit.

Composition utility: Forward descriptor

utils/components.py — Forward[T] / forward() for attribute forwarding on composed objects.

Type annotations are mandatory

Basedpyright recommended. Ruff includes ANN (private return types ANN202 ignored). Prefer PEP 695 aliases except where msgspec requires plain assignment (Unset).

Function complexity (C901)

Ruff's C901 stays at its default 10 (no mccabe section in pyproject.toml): one decision point per if / elif / for / while / except handler, one per match case past the first, plus one per nested def. Boolean operators, ternaries, comprehensions, with, and assert are free — so a wide flat guard is fine and a case-per-branch dispatcher is not. That is the intent: the few functions over it here keep a # noqa: C901 with the reason after the codes (— one branch per event type), because the count is what those functions are. PLR0912 is ignored (same branches, looser default, and C901 already flags every function it would); PLR0911 / PLR0915 stay — returns and statements are different axes.

A nested function is counted twice: its decision points are added to the enclosing function and reported on the nested function itself (def outer with one helper if and one own if scores 3; the helper scores 2 on its own). A function that only scores high because of its closures is asking for those to be hoisted to module level, not for a wider limit.

Roadmap notes (single-user → platform)

  • Phase D (context quality): soft char budget, request compact, /compact, richer errors/cancel, workspace sessions. Context size is char estimate plus optional API usage when reported.

  • No local tokenizer stage for now.

  • Phase E: tooling depth (grep/glob, VCS backends; prefer edit_replace / edit_lineno over model-generated patches).

  • Phase F (providers + usage v2): API usage + char-based estimate fallback; session/turn totals; /status + end-of-turn. Optional later: cost, real tokenizer.

  • Phase G (CLI polish + hardening) — single-user only; multi-tenant stays Phase H.

    Done

    • G0: prompting (ask/choose/form/confirm) + chat tools (ask_user_line/ask_user_choice/ask_user_form)
    • G1: ReasoningDeltaEvent, /stream, /verbose
    • G2: /rename, /delete (confirm), /export md|json
    • G2.5: Click slash registry (cli/slash.py) + awaitlet for sync Click / async memory; auto /help; completer from slash.list_commands
    • G3: multiline """ … """ input (cli/input_text.py); /edit via $EDITOR (edit_text_in_editor)
    • G4: plyngent chat -p/--prompt (+ non-TTY stdin); exit codes 0/1/2/3; --yes / non-interactive confirm deny; --stream/--no-stream, --quiet
    • G5: PTY master FD non-inheritable; read_pty/close_pty via to_thread; PtyManager.close_all() on chat exit; --log-level; clearer invalid TOML errors; export/status stay secret-free
    • G6: README, doc/plyngent.example.toml, AGENTS overview/CLI notes

    Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).

    PTY / process model (decision)

    Today PtyManager (tools/process/pty_session.py) is in-process: pty.openpty + os.fork → child execvp, parent holds master FD; read_pty/close_pty run via to_thread. Session registry is process-global. Safe enough for single-user CLI if the child path stays fork-then-exec only.

    Question Answer
    Separate PTY supervisor process so the main app is safer with threads/greenlets? Not for Phase G. Defer to Phase H (sandbox / multi-tenant) or if we hit real FD-leak / freeze bugs.
    Why not now? Fork-then-exec is already the right shape; rewrite cost (IPC, lifecycle, tests) dwarfs single-user CLI risk. awaitlet greenlets are slash-only; PTY is not forked from a worker thread today.
    G5 (done) Master FD non-inheritable; read_pty/close_pty via to_thread; chat exit close_all; fork stays on loop/main thread.
    Phase H Optional PTY helper process (JSON/Unix socket), or subprocess+PTY with asyncio reaping; sandboxed tools, multi-session isolation.
  • Phase H: multi-tenant platform (auth, sandboxed tools, web/). Optional out-of-process PTY host if isolation is required.

Commit messages

Scoped commit messages, not conventional commits:

<scope>: <brief description>

Examples: deps: add fastapi, core/mq: fix incorrect message sending, router: add routing service for xxx, test/webserver: change test client, ci/lint: run ruff style check.

Check git log for the full convention.