~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/asyncai

0
0
main
Go to filet
Code
README.md

asyncai

Agent runtime ported from the core event loop written in python at sshai

  • typed action/event streams
  • dynamic task multiplexer
  • agent runtime and thread lifecycle
  • waiting rules
  • OpenAI-compatible chat completions
  • MCP tools over stdio
  • atirc bridge
  • Claude Code session transcripts

Architecture: docs/adrs/0001-architecture.md

kcp MCP servers: docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md

Build

cargo build --release
cargo install --path .

Binary: target/release/asyncai.

Quick start

Echo backend, no model required:

printf 'hello\n' | cargo run -q -- --agent-name alice

OpenAI-compatible endpoint:

export OPENAI_BASE_URL=https://api.example.com/v1
export OPENAI_MODEL=my-model
export OPENAI_API_KEY=sk-...

printf 'hello\n' | cargo run -q -- --agent-name alice

ASYNC_LLM_BASE_URL, ASYNC_LLM_MODEL and ASYNC_LLM_API_KEY are accepted as aliases. No endpoint is built into the binary.

atirc

asyncai atirc subscribes and publishes directly through the atirc Rust library. No atirc sub stdin pipeline, no atirc pub subprocess.

export ATIRC_TOKEN=...
export OPENAI_BASE_URL=https://api.example.com/v1
export OPENAI_MODEL=my-model

cargo run -q -- atirc --channel '#alice'

Bidder KCP watcher agent

cargo install --path . \
  && sudo cp \
    ~/.cargo/bin/socialweb-computer-kcp-read* \
    ~/.cargo/bin/asyncai* \
    /usr/local/bin/ \
  && sudo -E -u swc-bidder \
    asyncai atirc \
    --mcp-stdio /usr/local/bin/asyncai-mcp-simple \
    --mcp-stdio /usr/local/bin/socialweb-computer-kcp-readonly \
    --verbose 2>&1 \
  | tee bidder-agent.ndjson \
  | yq -p=json -P

TODO iroh.computer peer to peer irc refactor of atirc.

Objective: Remove the need for the long lived token and server side of atirc.

  • Traverse owner graph (Leverage policy engine eval maybe?)
  • Leverage atirc send|recv style streams
  • Leverage Event Stream watches for new agents/associated ATproto accounts/repos.
  • Keep bounded cache with 10 second ttl.
  • Open atirc send/recv duplex streams to each agent/operator channel stream.
  • If cache miss on inbound iroh connection wait for event stream of new assocaition, if longer than 10 seconds (ttl) and no associated account with matching stream ticket, drop connection. Otherwise if it comes in counter verify atirc send/recv style, add to mesh of connections.
  • APIResource definition(s) within socialweb-computer-kcp
    • Run as HostProcess - Add submodule then binary to versions.json with build
    • Add example kubectl YAML apply based deployment
  • Refactor atirc tunnel/atirc client weechat to use this.
  • This archetecture should be good for the atirc refactor, plan and implement.

Token resolution:

  1. --token, then ATIRC_TOKEN.
  2. Else ATP_USERNAME + ATP_PASSWORD auto-mints a long token with --agent-name as the nick.

ATIRC_PDS overrides the PDS. When unset, it is derived from ATP_USERNAME. ATIRC_ENDPOINT overrides the default atirc endpoint.

flagmeaning
-t, --token <TOKEN>long-lived atirc token
-c, --channel <CHANNEL>channel; repeatable
--cursor <N>last sequence seen; -1 = new only
--agent-name <NAME>agent nick; also used for auto-mint
--mention-onlyonly handle messages mentioning the nick
--max-turns <N>stop after N published replies
--verboseNDJSON logs: connection, inbound messages, run_code and MCP calls/results, publishes, output records

Behavior:

  • Agent name comes from the connection event; --agent-name overrides.
  • Own messages are filtered by token identity. For a subhandle token, the owner account handle is not treated as self.
  • Every other message is handled by default.
  • Message text publishes without a nick prefix.
  • Replies are split into IRC-sized messages: each non-empty line is published separately, and lines over 400 bytes wrap at word boundaries. Blank lines are dropped.
  • atirc messages are written to sessions as task notifications under a synthetic atirc sub ... monitor description, not as user messages.

Multiple agents, one process each:

ATIRC_TOKEN=$(cat token-a) cargo run -q -- atirc --channel '#chan'
ATIRC_TOKEN=$(cat token-b) cargo run -q -- atirc --channel '#chan'

MCP tools

Build the bundled HTTP GET server:

cargo build

Use it with a tool-calling model:

export OPENAI_BASE_URL=https://api.example.com/v1
export OPENAI_MODEL=my-model

./target/debug/asyncai \
  --base-url "$OPENAI_BASE_URL" \
  --model "$OPENAI_MODEL" \
  --mcp-stdio "./target/debug/asyncai-mcp-simple"

asyncai-mcp-simple exposes:

toolargumentsresult
http_get{"url": "http://..."}{"status", "content_type", "body"}; connection failure → error

--mcp-stdio is repeatable. Each entry is one stdio MCP server command.

Code mode

The model gets one tool, run_code. MCP tools are Python async functions inside it; Monty runs the program and only the result reaches the model (CodeMode pattern).

What the model writes:

import asyncio
cms, events = await asyncio.gather(
    list_objects(workspace="root:app", resource="configmaps", all_namespaces=True),
    get_events(workspace="root:app"),
)
{"configmaps": names(cms), "events": count_by(events, "type")}
  • MCP output: unbounded. JSON → dict/list, text → str, tool error → RuntimeError.
  • Stubs carry types from MCP outputSchema; docstrings end in Returns:.
  • Bad kwargs → TypeError before any MCP call.
  • Helpers: items(x), names(x), dig(obj, "a.b"), count_by(x, "a.b"). asyncio, json, re pre-imported.
  • Last expression is the result. Variables persist for the conversation (one REPL per thread, across turns); "restart": true resets.
  • Result > 20 000 chars → rejected with preview; data stays in the variable.
  • No files, env, network, subprocesses.

Limits per run_code:

limitdefault
Python execution30 s
wall clock300 s
each tool call120 s
tool calls100
heap (pool sandbox)4 GiB

Sandbox: pool (default when monty found) = worker subprocess, heap cap, crash isolation. In-process = fallback, no heap cap, warn logged.

cargo +1.97.0 install monty-runtime --locked   # installs `monty`

./target/debug/asyncai \
  --mcp-stdio ./target/debug/asyncai-mcp-simple \
  --code-sandbox pool \
  --code-max-memory $((8 * 1024 * 1024 * 1024))

Preamble run in every new scope:

printf 'WS = "root:app"\n' > preamble.py
./target/debug/asyncai --mcp-stdio ./target/debug/socialweb-computer-kcp-readonly \
  --code-preamble preamble.py

Mutating calls (apply/patch/scale/delete) always log warn, no --verbose needed:

asyncai atirc -c '#alice' --mcp-stdio socialweb-computer-kcp-readwrite 2>&1 \
  | jq -c 'select(.mutating)'

Design: docs/adrs/0004-code-mode.md

Watches

Tell the bot to watch something; changes arrive as new turns. Push only: no polling anywhere (ADR 0005).

ATIRC_TOKEN=$(atirc token) atirc pub -c '#alice' \
  'alice: watch configmaps in root:app (all namespaces); tell me when one appears or disappears'

What the model writes:

await watch_start(
    name="cm",
    uri="kcp-events://watch/root:app/configmaps?namespace=*",
    on_event="f\"{event['type']} {event['object']['metadata']['name']}\"",
)

Trigger a change from anywhere:

kubectl --kubeconfig "$K" --server "$SERVER/clusters/root:app" \
  -n default create configmap watch-probe --from-literal=a=b

About 3 s later the bot gets:

<watch-event name="cm" uri="kcp-events://watch/root:app/configmaps?namespace=*">
ADDED watch-probe
</watch-event>

Built-ins in run_code:

functiondoes
list_resources()resources and templates (uri, template, example); subscribable ones can be watched
read_resource(uri=...)read one resource
watch_start(name=, uri=, on_event=None)subscribe; on_event is Python per event (type, object, old, changed), None result drops it
watch_stop(name=) / watch_list()manage watches
  • Works with any MCP server on spec 2026-07-28 that exposes subscribable resources. Contract: ADR 0005.
  • on_event runs read-only (no mutating tools, no new watches); syntax errors fail watch_start.
  • Bursts are merged (3 s quiet, at most 15 s); a line repeated for the same object is dropped. Notices during a turn join one follow-up turn.
  • Without on_event, lines show what changed: MODIFIED bid-1 (status.phase: Deciding→Published).
  • Notices are session task-notifications, never user messages. A reply of NO_REPLY (or empty) posts nothing.
  • --watch-state watches.json keeps watches across restarts.
  • Limits: 5 watches, 20 lines per notice, resubscribe with backoff on disconnect.

Generic demo, no Kubernetes needed:

./target/debug/asyncai --mcp-stdio ./target/debug/asyncai-mcp-simple
# model: await watch_start(name="notes", uri="memo://notes")
#        await memo_set(key="todo", value="ship")   -> notice: ADDED todo

socialweb-computer kcp servers

binaryaccesstools
socialweb-computer-kcp-readonlyminted read-only token per workspacelist_kcps, list_workspaces, get_api_resources, list_objects, get_object, get_logs, get_events, readonly_credentials
socialweb-computer-kcp-readwriteadmin kubeconfigread tools + apply_object, delete_object, patch_object, scale_object
cargo build --bins

./target/debug/asyncai \
  --mcp-stdio "./target/debug/socialweb-computer-kcp-readonly --ssh hat-7 --sudo --allow-prod"

./target/debug/socialweb-computer-kcp-readonly \
  --kubeconfig /path/to/kcp/admin.kubeconfig
  • Output: structuredContent JSON (kubectl -o json). Arrays wrapped: {"workspaces": [...]}, {"resources": [...]}. get_logs, delete_object: text.
  • kcp optional: the only running kcp is used.
  • Watchable: kcp-events://watch/{workspace}/{resource}{?namespace,labelSelector,fieldSelector,kcp} (event-log resource, kubectl get -w; MODIFIED events carry old).
  • Readable: kcp-api://{workspace}/resources{?kcp} (same JSON as get_api_resources).
  • Unknown resource names fail with did you mean: ....
  • Annotations: read tools readOnlyHint; delete_object destructive.
  • Read-only server never reads with the admin kubeconfig; tokens last 900 s and are never returned.
  • --ssh HOST runs commands remotely; --sudo adds sudo -n.
  • socialweb.computer, hat-7, hat-9 need --allow-prod.
  • apply_object(dry_run=True): server-side admission, nothing stored.

Design: docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md

Logs

All stderr is NDJSON. With atirc --verbose:

eventwhen
model.tool_call / model.tool_resulteach run_code
mcp.tool_call / mcp.tool_result ("via": "code_mode")each MCP call from Python
model.turn_summaryend of turn: rounds, nested_calls, bytes_in, bytes_out
code_mode.sandboxstartup: pool or in-process
watch.started / watch.notice / watch.failedwatch lifecycle (always); watch.event with --verbose
asyncai atirc -c '#alice' --mcp-stdio socialweb-computer-kcp-readonly --verbose 2>&1 \
  | jq -c 'select(.event == "model.turn_summary")'

Sessions

Session transcripts are written by default for asyncai and asyncai atirc.

First stdout line:

{"work_name":"session","result":{"session_id":"<uuid>","session_file":"<path>","harness":"claude-code-cli","agent_name":"<name>","harnesses":[...]}}

Claude Code transcript location:

<root>/projects/<encoded-cwd>/<uuid>.jsonl

Default root for claude-code-cli: $CLAUDE_CONFIG_DIR or ~/.claude. Other harnesses own their own roots.

Resume:

deepseek-claude -p --resume <uuid> 'Reply with exactly: resumed-ok'

While the bot is running, claude --resume appends to the live file (session.foreign_append warns). Resume a copy instead:

cp "$F" "${F%/*}/$(uuidgen).jsonl"

Records include user messages, assistant messages, MCP tool calls, and tool results. atirc channel messages are written as queued system notifications, not user messages. Each record is flushed. Existing files are never overwritten; each run allocates a new UUID.

Flags:

--session-dir claude-code-cli=/path/to/claude-root
--session-format claude-code-cli   # repeatable
--monitor "tail -f /tmp/events.log" # repeatable
--no-session

Monitor-only interactive mode, no stdin, no user records:

./target/debug/asyncai --no-stdin --monitor "tail -f /tmp/events.log"

Each --monitor stdout line becomes a Claude Code task notification in the transcript:

queue-operation:enqueue
  → attachment:queued_command
  → queue-operation:remove

Monitor events are captured for the session only; they are not injected into the agent conversation.

Session format details: docs/adrs/0002-claude-code-session-files.md

Prompts

prompts/atirc.md
prompts/interactive.md
prompts/atirc-socialweb-computer.md   # appended to atirc.md by --prompt atirc-socialweb-computer

Both are embedded at build time. {agent_name} is substituted.

socialweb-computer node (which objects matter, ready-made monitors):

asyncai atirc -c '#alice' --prompt atirc-socialweb-computer \
  --mcp-stdio socialweb-computer-kcp-readonly --watch-state watches.json

Override at runtime:

./target/debug/asyncai --prompt-file ./my-prompt.md

A ## On Start section (any heading level) is cut from the system prompt and run once as the first turn:

## On Start

Set up monitors for bidder compute contract events.

CLI flags

flagmeaning
--model <MODEL>model name for the OpenAI-compatible backend
--agent-name <NAME>agent name; atirc defaults to connection nick
--base-url <URL>OpenAI-compatible base URL
--api-key <KEY>API key for that endpoint
--mcp-stdio <COMMAND>stdio MCP server command; repeatable
--code-sandbox <auto|in-process|pool>where run_code runs; default auto
--monty-worker <PATH>worker binary; else MONTY_WORKER, else monty on PATH
--code-max-memory <BYTES>pool worker heap cap; default 4 GiB
--code-preamble <PATH>Python run in every new run_code scope
--watch-state <PATH>save watches here; restore them at startup
--max-output-tokens <N>cap per model request; default 8192
--model-timeout <SECS>wall-clock limit per model request; default 600
--prompt-file <PATH>markdown prompt override
--prompt <NAME>built-in prompt: atirc, interactive, atirc-socialweb-computer
--session-dir <FORMAT=PATH>per-format session root; repeatable
--session-format <FORMAT>session harness format; repeatable
--monitor <COMMAND>capture stdout lines as monitor events; repeatable
--no-stdinrun monitors only; do not read stdin
--no-sessiondisable session transcripts

Run asyncai --help for the full surface.

Test

cargo test

Mock models, fake atirc bus, fake kubectl. No live services.

Pool sandbox tests need the worker:

MONTY_WORKER=$(command -v monty) cargo test --test code_mode_pool

Documentation

Not ported

  • policy engine workflow execution / GitHub Actions
  • Caddy reverse-proxy management
  • tmux attachment, Unix sockets, SSH daemon
  • OpenAI Agents SDK integration; backend trait is the seam
  • keyring persistence; in-memory KV store only