19 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, and REPL/one-shot chat. Multi-tenant router/ / web remain 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
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/OpenAICompatibleClientvianiquestsasync + SSE;chat_completions,modelsonly.openai_compatible/config.py—OpenAIConfig(token + base URL).openai/model.py— OpenAI Responses API (ResponsesCreateParam,Response, function_call items, stream events).openai/client.py— platformOpenAIClient(kind="responses"): chat completions +responses/get_response/delete_response.anthropic/model.py— Anthropic Messages request/response + SSE event models.anthropic/client.py—AnthropicClient(kind="messages"):POST /messages+GET /models.- DeepSeek —
DeepseekOpenAIClient(kind="chat_completions"); models addreasoning_content,prefix,ThinkingOptions. Config default model ids:deepseek-v4-flash,deepseek-v4-pro.DeepseekResponsesClient(kind="responses") extends the OpenAI Responses client forconvention = "responses"(SSE stops at terminal events — DeepSeek sends no[DONE]).DeepseekAnthropicClient(kind="messages") extendsAnthropicClientforconvention = "anthropic"(basehttps://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.
Runtime (runtime/)
create_client(provider) maps config Provider → protocol client by effective preset (model-level preset/url overrides apply). 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; per-request timeout from config (default 30s).
Memory (memory/)
Async SQLAlchemy + aiosqlite. MemoryStore: schema init (+ lightweight SQLite ALTER for new columns), default local user, sessions (bound to workspace path; optional provider_name/model), 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 syntheticChatCompletionResponse/ stream chunks. responses_bridge/responses_dispatch: chat ↔ OpenAI Responses; stream text/reasoning deltas; tool calls + usage onresponse.completed.messages_bridge/messages_dispatch: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments.- Provider-side tools:
OpenAIProvider.provider_tools(list of dicts; default[{type="web_search"}]when omitted;[]disables) threaded viaChatAgent/run_chat_loopinto Responses only; never executed byToolRegistry. @tool/ToolRegistry: decorator infers JSON Schema from type hints; execute tools by name.run_chat_loop: multi-round tool loop; default streaming text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (API-calibrated after first usage when available); cooperative cancel points; optionalon_limit.ChatAgent: optionalMemoryStore(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 or after committed tools — does not re-run those tools)./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.- Events: text_delta, reasoning_delta, assistant_message, tool_call/result, max_rounds, error (
retryable/source), cancelled (reason), usage (TokenUsage). - Usage: API
usagefrom completions (stream withinclude_usage); char≈token fallback (~4 chars/token) when omitted; context size = last requestprompt_tokens(API preferred);last_turn_usage/session_usageare billed sums (tool rounds re-send history); CLI end-of-turn +/status. - Config
[agent]:system_prompt(persona; defaultDEFAULT_SYSTEM_PROMPT;""omits persona),tool_directives(tool playbook; defaultDEFAULT_TOOL_DIRECTIVES;""omits playbook; both empty → no system),max_tool_result_chars,parallel_tools,confirm_destructive,path_denylist,max_context_tokens(default 200k est. tokens). Truncated tool results carry a model hint ([Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]) so the model can ask the user to raisemax_tool_result_chars; request-time compact shrinks (hint=False) keep the terse...[truncated N characters]marker.
Tools (tools/)
Module-level @tool handlers. Call set_workspace_root() before use.
workspace: path resolve under primary root or temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; not skipped by YOLO); clearer deny messages.file:read_file(with_lineno,max_charsdefault 32k; results start with a 1-basedL{begin}-{end}range),write_file,listdir,tree(model picksformat=markdowndefault /flatpaths /decorated; VCS + default noise dirs + optionalskip_dirs/ denylist walk),glob_paths,grep_files(regex, skip VCS/binary),edit_replace(max_replaces, reports remaining matches),edit_lineno(1-based range; only lines read viawith_lineno; read state resets on any file write/edit/copy/move/delete),copy_path/move_path/delete_path,new_temporary_workspace(system temp allowlist; cleanup on chat exit; no session rebind),get_truncated(resume any truncated result via itstruncate_token=...).process:run_argv(argv, no shell, timeout, optional stdin/env);run_argv_batch(serial steps,pipe_outon provider,mix_stderr,stop_on_error); PTYopen_pty/read_pty/write_pty(literal) /write_pty_keys(escapes) /ask_into_pty(human→PTY only;secretno-echo; answer never in tool result) /close_pty(POSIX:pty+fork; Windows: ConPTY viapywinptyenv 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 viawrite_pty_keysonly (\xHH,ctrl+x,key=esc); secrets viaask_into_pty(notwrite_ptydata); 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.
- Destructive confirms:
classify_danger+ToolRegistry(on_confirm=…); CLI default deny; configconfirm_destructive/path_denylist. Soft confirm also for shells/REPLs andpython|bash -cone-liners (argv + code preview); deny may include a user comment for the model. Session YOLO:/yolo on|off|onceand--yes(skip soft confirms; hard denylists unchanged;onceexpires after the next user turn). vcs: read-only VCS tools (vcs_kind/vcs_status/vcs_diff/vcs_log/vcs_branch) viaVcsBackendprotocol; git implemented; detectors are pluggable for other systems.chat: human prompts as tools —ask_user_line/ask_user_choice/ask_user_form(sharedpromptingcore);wait(line prompt with timeout; Enter disturbs).todo: LIFO stack of task groups (not a queue of tasks) —todo_pushcreates one group of siblings;todo_popremoves the whole top group;todo_updateby id; 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= forgedtodo_listcall + realstack.render()result + ToolCall/Result events;none).net/fetch: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (asynciogetaddrinfo). 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 unlessallow_http_downgrade.user_agenttool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.mcp(tools/mcp.py): register one server's advertised tools into the catalog as namespacedmcp__<server>__<tool>definitions withToolSource(kind="mcp", plugin_id=server); tags LOCAL (+ READ_ONLY when the server config setsread_only) — never YOLO/TRUSTABLE.register_mcp_toolspurges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started byMcpManager.DEFAULT_TOOLS/ catalog: file + process + vcs + chat + todo + net (fetch) for local surface.ToolTag.READ_ONLYmarks non-mutating tools (read_file,get_truncated,listdir,glob_paths,grep_files,tree,todo_list,wait,ask_user_*,vcs_*,fetch);ToolRegistry.clone(read_only_only=True)selects just those for safe side turns / planning, andread_only_context()(intools/context.py) makesfetchaccept 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).
CLI (cli/)
Click app + readline REPL. Entry: plyngent / python -m plyngent.
plyngent chat: provider/model (flags or interactive; Tab via readline inprompting); sessions storeprovider_name/modeland restore on resume; SQLite via[database](file DB under user data when url unset; expliciturl = ":memory:"kept + warn); workspace-bound; resume latest for cwd/--workspaceby default (--new/--session). One-shot:-p/--promptand non-TTY stdin; exit codes 0/1/2/3;--yes(YOLO on),--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).- Slash: Click group in
cli/slash.py+awaitletfor async work; Tab completer from registry + ParamTypeshell_complete. Multiline"""…""";/editvia$VISUAL/$EDITOR(blocking only)./yolo on|off|oncefor soft destructive confirms./model --persist//models --persistwrite model catalog entries into TOML./todosfor human show/push/pop/clear of the todo stack./mcp(listdefault) prints per-server status/tool counts + last stderr lines;/mcp reconnectre-reads config, restarts every enabled server, and rebuilds the tool registry. /btwside questions: forked session; main transcript/DB untouched.--tools=read(default) exposes onlyREAD_ONLYtools underread_only_context()(fetch=GET) with the main session view;--tools=nooff;--tools=fullwhole-registry clone with fresh session. Tools off: default falls back to no tools; explicitread/fullerrors.- Explicit
/resumeor--sessionfrom another workspace prompts: keep / update / abort. - Failed/cancelled turns: user kept; committed tool rounds kept (side effects not re-run on
/retry); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then/retry. plyngent providers,config path|edit. Config open:$VISUAL/$EDITOR(wait), else system default (xdg-open/open/startfile, non-blocking)./editstays blocking-only. No providers → optional edit then reload when waited.- Tools default on; workspace defaults to cwd;
--max-roundsdefault 32. Readline history under platformdirs (repl_history). PTY:close_allon 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).
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_linenoover 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) +awaitletfor sync Click / async memory; auto/help; completer fromslash.list_commands - G3: multiline
"""…"""input (cli/input_text.py);/editvia$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_ptyviato_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→ childexecvp, parent holds master FD;read_pty/close_ptyrun viato_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_ptyviato_thread; chat exitclose_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. - G0:
-
Phase H: multi-tenant platform (
router/, 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.