AGENTS.md
Repo rules. Hard constraints for work in asyncai.
Scope
- Rust/tokio port of
sshai/src/sshai/agi.pycore event loop. - Keep: actions/events/state,
WorkSetmultiplexer, orchestrator waiting rules, agent runtime, thread lifecycle, NDJSON output. - Exclude: policy engine, Caddy, agi.py MCP integrations (SSE), tmux attach, SSH daemon, keyring.
- Local stdio MCP tooling is in scope.
- Full cargo project. Tests required.
Never
- Never hardcode endpoints or ports. Endpoint comes from env/CLI at runtime.
- Never let tests call live model or real atirc server. Mock all.
- Never overwrite, truncate, rename, delete, or append to existing Claude Code files.
- Never create global session dir. Root is per harness.
- Never add
agent -> sender:prefix to atirc replies. atirc identity identifies sender. - Never gate atirc replies by nick unless
--mention-onlyexplicitly passed. Default = all messages except own identity. - Never use unreputable/unmaintained crates for protocol work. MCP = official
rmcp. - Never leave architecture prose in README. Architecture goes in ADRs.
atirc
- Mode:
asyncai atirc. UsesatircRust library through a pinned git dependency. No stdin pipe. Noatirc pubsubprocess. - Args mirror
atirc sub:-t/--token, repeatable-c/--channel,--cursor. - Token:
--token>ATIRC_TOKEN> auto-mint fromATP_USERNAME+ATP_PASSWORDusing--agent-nameas nick. - PDS: use
ATIRC_PDSif set; otherwise derive it fromATP_USERNAME. - Agent name/self identity come from token and connected event;
--agent-nameoverrides. - Replies publish through the library without a prefix.
- Replies split into IRC-sized posts: one non-empty line per post; lines over 400 bytes wrap at word boundaries. Blank lines dropped.
- No self filter is NOT allowed; agents echo themselves. Filter own identity.
- Subhandle token: filter only its subhandle; owner account handle is not self. Account-level token filters the account handle.
--max-turns Nbounds loops.--mention-onlyopt-in nick gate. Default handles all non-self messages.--verboseemits NDJSON stderr logs: connection, inbound messages, MCP calls/results, publish success, output records. No free-form log lines.- atirc channel messages are session monitor events, not user records. Synthetic monitor description:
atirc sub -c ... --cursor .... - Prompts live in
prompts/atirc.md,prompts/interactive.md.{agent_name}substituted.--prompt-fileruntime override.--prompt NAMEpicks a built-in;atirc-socialweb-computer=atirc.md+prompts/atirc-socialweb-computer.mdaddendum (objects that matter, monitor recipe). Its Python example is executed bytests/watch.rs; keep it runnable. AOn Startheading (any level) is cut from the system prompt and delivered once at startup as noticeprompt:on-start(session monitor event, never a user record). - Live tests:
#test-asyncaiopen.#test_asyncaimaps to handletest.asyncai, post restricted.
Sessions
- Default on for
asyncaiandasyncai atirc. - Library:
src/session/mod.rs+SessionHarness; harnesses undersrc/session/harnesses/<format>/. claude-code-clifirst harness. Claude format only there.- Each harness owns root, layout, UUID.
- First stdout line: NDJSON session record with UUID/path/harness.
- Interactive stdin line → user record.
--no-stdin→ monitor-only, no user records. atirc channel message → monitor event records, never user record. - Every accepted message, assistant message, MCP tool call, MCP tool result updates every active harness.
- Add record, flush immediately.
- Random UUID v4 per process per harness. Retry on collision.
mkdironly. Open withcreate_new(true). Existing files untouched.- Claude path:
<root>/projects/<encoded-cwd>/<uuid>.jsonl. Encode: ASCII alnum and-kept; everything else-. - Claude default root:
$CLAUDE_CONFIG_DIRor~/.claude. --session-dir FORMAT=PATHrepeatable. No bare path.--session-format FORMATrepeatable. Defaultclaude-code-cli.--no-sessiondisables.- Must load via
claude --resume <uuid>. Test:deepseek-claude -p --resume <uuid> .... - Tool transcript order:
user:text | assistant:tool_use | user:tool_result | assistant:text. - Monitor capture:
--monitorstdout line →queue-operation:enqueue+attachment:queued_command+queue-operation:remove. - Monitor IDs match:
attachment.source_uuid == remove.commandUuid;attachment.delivery_id == remove.deliveryId. - Queue-operation records do not enter
parentUuidchain. Attachment does.
MCP / tools
- stdio MCP client via
rmcp. - Simple server:
src/mcp/servers/simple/, binaryasyncai-mcp-simple. - Export
http_gettool:{url}→ structured{status, content_type, body}; connection failure →is_error. - CLI
--mcp-stdio "command args"repeatable. - Library:
McpToolExecutor+OpenAiCompatibleBackend::with_tool_executor. - Every model request sends
max_tokens(--max-output-tokens, default 8192) and has a timeout (--model-timeout, default 600 s);finish_reason: lengthlogsmodel.truncated. Live: an uncapped local model decoded 71 486 tokens and hung the agent. - Tool loop max 8 rounds. On the last round tools are withdrawn and a user-role note (never a late system message; chat templates reject it) tells the model to answer with what it has, so a run always replies. Tool errors fed back as text.
- Tool events go through
ToolObserverto sessions. - Code mode (ADR 0004): with
--mcp-stdio, the model sees onlyrun_code. All MCP calls are made from model-written Python run by Monty (src/code_mode/). Never expose MCP tools directly to the model. - Sandboxes:
pool(monty-pool worker subprocess; heap cap--code-max-memory, default 4 GiB; crash isolation) andin-process(engine thread; no heap cap).--code-sandbox auto(CLI default) picks pool when amontyworker is found, else in-process +warn. Library default: in-process. - Both sandboxes speak
code_mode::protocol(Job in,HostEventout). Shared call validation incode_mode::dispatch. Never duplicate it per sandbox. - In-process Monty runs on a dedicated thread (values are
!Send). Never run Python on a tokio worker. - REPL state is per conversation thread via
ToolExecutor::thread_scope(thread_id)(RunRequest.thread_id; LRU of 8). The runtime never overlaps runs on one thread, so no extra locking. Never share a REPL across threads or agents. Thread history keeps only user/assistant text, so variables are how tool results survive across turns. Helpers (helpers.py) +--code-preamblefed on each new scope and onrestart. Helper names (items,names,dig,count_by,to_json) are reserved: snippets rebinding them are rejected. Variables in scope reach the model viaToolExecutor::context_noteon the newest user message; never put per-turn state in tool descriptions (breaks prompt cache). - Sandbox has no fs/env/network/subprocess access.
OsCall→PermissionError. - Nested call checks: keyword-only, unknown kwarg →
TypeError, missing required →TypeError,Noneoptionals dropped, budget 100. - Limits (
CodeModeLimits): 30 s execution, 300 s wall perrun_code, 120 s per tool call, 100 nested calls, 10 000 suspensions, 1 MiB prints, 20 000-char result guard, 4 GiB pool heap. - MCP tool output is unbounded. Never truncate in the client or servers; filtering happens in Python.
McpToolExecutorroutes each tool name to exactly one server; first server wins on duplicates (mcp.duplicate_toolwarn).- MCP results are JSON with
outputSchema+structuredContentwherever possible.structuredContentmust be an object: wrap arrays ({"workspaces": [...]},{"resources": [...]}). Failures →is_error(raised asRuntimeErrorin Python). - kcp tools: kubectl
-o json;kcpoptional (sole running kcp). Descriptions and schemas live insocialweb_kcp/spec.rs. - Every tool description ends with a one-line
Example:in code-mode Python. - Annotate tools: read →
read_only_hint = true; writes →read_only_hint = false; delete →destructive_hint = true. - Mutating nested calls always log
warnmcp.tool_call/mcp.tool_resultwithmutating: true, even without--verbose. --verboseNDJSON:model.tool_call/model.tool_result(run_code),mcp.tool_call/mcp.tool_result(via: code_mode),model.turn_summary,code_mode.sandbox.- Session transcripts record
run_codetool_use/tool_result. Nested calls go intoolUseResult.nested_callson the tool_result record, never as transcript records.
Watches (ADR 0005)
- Push only. Never poll, in asyncai, MCP servers, or model code. Waits are subscriptions, stream reads, post-event debounce, or post-failure backoff.
- MCP 2026-07-28 only: connect via
server/discover(Auto falls back toinitializefor tool-only servers); watches usesubscriptions/listen. No legacyresources/subscribe. - Generic layer:
resources::ResourceSource(McpToolExecutorimplements). Two read shapes: state (asyncai diffs) and event-log (application/vnd.asyncai.events+json,{"events":[{"seq",...}]}). - Code-mode built-ins:
list_resources,read_resource,watch_start,watch_stop,watch_list(routing prefixasyncai::).WatchManagerowns watches at agent level; they outlive runs. on_eventfilters run per event in the watch's own scope with a read-only catalog (no mutating tools, no watch built-ins). Must be source text (function reprs rejected). Dry-run atwatch_start;SyntaxError/NameErrorfail it.- Watchable servers: the first read of a watched URI awaits the change source starting (signal, not poll) and returns its error, so
watch_startfails fast. - Notices enter the core as
OrchestratorHandle::send_notice→MainWork::Notice(main.system.notice_stream) → same waiting rules as input. They coalesce with active runs viaqueued_run. Never inject notices as user records in sessions: they areMonitorEvents. - CLI routing:
TurnTracker; anew_thread_run_createdclaims all unclaimed inputs; replies go to the latest claimed channel. Never per-input FIFO. - New MCP servers that want to be watchable: follow the ADR 0005 server contract (capability, discoverable URIs/templates with examples, listen, push from a native change source, no
ttlMs). Event-logMODIFIEDevents should carryold. - Events get
old+changed(dotted paths, depth 3, volatile metadata ignored); no-opMODIFIEDdropped. Lines repeated per object dropped. Trailing debounce 3 s,max_wait15 s. NO_REPLY(or empty) reply publishes nothing (reply.suppressed). Arun_codecall whose code is onlyNO_REPLYends the turn asNO_REPLY(model.no_reply_tool_call). Watch notices and prompts use it. Never rely on models emitting empty text.--watch-state PATHpersists specs (temp + rename) and restores at startup. No default path. On restore, invalid specs are dropped; transient failures stay saved (watch_listshowsrestore_failed) until started again or stopped.
Logging
- All stderr output is NDJSON: one JSON object per line with
levelandevent. Usecrate::log::emit. Never free-formeprintln!. - Binary mains use
log::exit_on_error(fatal error → NDJSONfatal, exit 1).
CLI
- Global flags:
--model,--agent-name,--base-url,--api-key,--mcp-stdio,--code-sandbox,--monty-worker,--code-max-memory,--code-preamble,--prompt-file,--session-dir,--session-format,--monitor,--no-stdin,--no-session. - atirc flags:
-t/--token,-c/--channel,--cursor. - Tools require a base URL.
- Keep examples copy-pastable.
Docs
- ADRs:
docs/adrs/. 0001-architecture.md: architecture, mermaid diagrams, package tree.0002-claude-code-session-files.md: session discovery, format, harness design, no-overwrite, verification.0003-socialweb-computer-kcp-mcp-servers.md: kcp MCP servers, read-only tokens.0004-code-mode.md:run_code, Monty engine, limits, unbounded MCP output.- README: user-facing only. Quick starts, flags, atirc, MCP, sessions, tests, ADR links. Clean mermaid in ADRs. Style reference:
../atirc/README.md. - Behavior change → update README + relevant ADR.
Tests / gates
Before done:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
- Tests mock LLM, MCP tool calls, and atirc bus.
- Pool tests (
tests/code_mode_pool.rs) skip unlessMONTY_WORKERis set. CLI tests pin--code-sandbox in-process. - Session tests use temp roots. No
~/.claudewrites from tests. - Live/manual checks use env-exported endpoint; never commit endpoint.
- Session resume checked with
deepseek-claude -p --resume <uuid>.