Skip to content

About

amh: agent manager helper

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

155 Commits

Folders and files

Repository files navigation

amh: agent manager helper

(authored by agents unless marked 🧑)

one program for agents and managers; usage lives in amh --help, not here

  • 🧑 "do a port and replace things part by part ... at the beginning use py03 to call everything and only have the Rust part do command line parsing ... Test in production"
  • all Rust (src/), one binary, no interpreter; the port from Python is finished
    • src/cli.rs: the command tree with every help text (clap)
    • src/main.rs: parses the command line into one JSON object (group, action, the values), logs it, runs it, exits with its status
    • src/actions.rs: runs the action; a refusal is one line amh: ... on standard error and exit status 1
    • how it was ported: one part at a time, each left in production for a while before the next; settings -> task files and task list -> todo/task actions -> agents -> mail -> watcher and guest mailbox -> the remaining actions
    • the human's earlier design choices: docs/design-choices.md
    • the human's architecture vision (state model apart from input/output, simulation, task lifecycle and dependencies, events, messaging, boundaries): docs/vision.md
    • rule taken from it for every part: pure functions over plain data in one module, input/output in a thin caller (src/taskfile.rs is the pattern)
      • pure: taskfile (but its atomic write), tasks, work, decide, guest, and the text functions of mail and omnigent
      • input/output: actions, agents, store, settings, omnigent, mail, watch, view, log
  • build: cargo build --release; install: cargo install --path ., then amh manager watchers so the watcher runs the new binary
  • watcher: amh manager watch in the foreground; amh-watch.service is the user systemd unit for it
  • use-amh: stub that old command names link to
  • test: cargo test
    • the pure decisions on small made-up inputs: what each action changes in a task record or prints, the refusals, the texts, the tree, closing, replacing a manager, mail threads and building and parsing, the watcher, the guest mailbox, starting agents
    • no fake Omnigent or mail server: input and output is tested in production
    • before deploying a change, try it on a copy that touches nothing live: AMH_STATE_DIR = a temporary folder holding a copy of env.toml whose work_logs is a temporary root, whose mail.human_address is the agent's own address, and without [guest]
      • there, compare the old and the new binary on throwaway tasks: same output, exit status, and resulting files
      • the watcher: run it once there with made-up agent addresses and no mail login

records, all under the work-log root (work_logs in env.toml)

  • task file NAME.md
    • frontmatter: status runat managerat is_manager tool pending_task_items
      • runat: agent address omnigent://ID, or retired
      • status: running long_running blocked (+ blocked_on) done
      • item starting 🧑 = the human's request
    • body: free text; whole-line (notes); (pending) blocks
  • TODO.md: sections current: human pending: low priority: previous:, rows NAME.md ADDRESS
  • manager_mail/PREFIX-UID.txt: stored human email, Subject: line then body
  • name of a task file without .md = tag in email subjects [tag]

modules

  • Zulip (src/zulip.rs): human updates, uploads, task-topic replies, replacement deletion, and retained email migration aliases
    • transport settings and flow: docs/zulip.md
  • settings (src/settings.rs)
    • 🧑 "Separate config file, which should live in .config or something and get committed, and env, which should be in local state or something and include things like where the work_logs is"
    • config.toml in the config folder ($AMH_CFG_DIR, else $XDG_CONFIG_HOME/amh, else ~/.config/amh): same on every machine, committed; optional
      • [launch.TOOL] model, effort, manager_model, manager_effort
      • [compress] task (who is told to compress the human's mailbox; unset = off), unread_over (default 16), unread_urgent (default 30)
    • env.toml in the state folder ($AMH_STATE_DIR, else $XDG_STATE_HOME/amh, else ~/.local/state/amh): this machine only, never committed; required
      • work_logs, main_manager, [omnigent] url token, [mail] agent_address agent_password human_address human_mailbox_config stored_prefix, [guest] address name workdir stored_prefix
    • both files flatten to dotted keys (mail.agent_address); env.toml wins
    • the state folder also holds what the program records: the lock, the watcher log, and the amh-*.txt records
  • task files and task list (src/taskfile.rs): parse and render task files without a YAML library, find (pending) blocks, mark items, place a task's row in TODO.md, atomic file replace keeping permissions
    • checked against the previous Python on every file of the live root: same parse, render, blocks, and placement
  • actions (src/actions.rs): run does every action's input and output: settings, the lock, loading and saving, the caller's own task, files and standard input, the agents, the mail, what is printed
    • output goes out as it is made (tell human prints the sent Message-ID before it checks for unread mail); a reader that went away (amh agent list | head) is not an error
    • a text file (--file, --prompt) and standard input are read with \r\n and \r as \n
    • manager watchers restarts the unit and shows its status; manager worktree prints git status --porcelain of ~/.config and of the work-log root
    • tell guest runs here too
    • unlike the earlier Python, whose output and exit status the actions otherwise keep
      • a failure Python showed as a traceback (a file that cannot be read, mail that cannot be reached or sent, a task file that does not parse) is one line amh: ...; the exit status is 1 as before
      • manager rotate refuses when the old session lacks the tool, model, effort, or folder and no option gives it; before, the text None was passed on
      • manager watchers exits 1 when systemctl is killed by a signal; before, 0
      • a subject in the unread listings is quoted as the watcher log quotes (decide::quoted): control and invisible characters other than \n, \r, \t are shown as they are, not as \x.. escapes
      • a failed agent view is logged with exit=1
  • todo and task records
    • request stages, ownership, dependencies and events: docs/request-lifecycle.md
    • entries without task files: docs/task-list-entries.md
    • scheduled commands and standing agents: docs/scheduled-jobs.md
    • src/tasks.rs: the Task record and every change to it as pure functions: add, remove, reword, move an item; note; status; show; check of task files against the task list; the notice text for the human
    • src/store.rs: the work-log root on disk: the lock, loading and saving task files, the task list row, the caller's own task; shared by the actions, the watcher, and starting agents
  • amh agent view WHO (src/view.rs): the human's command-line view; attaches the caller's terminal to the agent's own tmux terminal that Omnigent runs (--read-only to only watch); a parked agent has none until a message wakes it
  • agents (src/omnigent.rs): every agent is an Omnigent session, reached as JSON over HTTP on the local server
    • 🧑 "the Rust port would only support Omnigent, not tmux": no tmux launch, paste, or status; an address that is not omnigent://ID is reported as not reachable
    • send: one message event; a multi-line message is followed by one typed line, because Claude Code treats multi-line input as pasted text and does not act on it alone
    • classify: pure; running, ready (idle, also when Omnigent has parked the runner), stuck (the tool waits on an unanswered prompt, so the session reads no messages), error (failed, with the harness error), missing
    • launch: needs exactly one TOOL-native-ui agent and one online host; every tool runs with no sandbox and no permission prompts; waits up to 90 s for the runner
    • approve_prompts: answers yes to permission prompts a session waits on; the watcher calls it once a minute for stuck agents and tells the manager; other kinds of prompt stay stuck and are reported
      • 🧑 "Sounds good, but should not be the default, which is no question any time at all"
    • stop: when Omnigent refuses because the session's terminal has already vanished (no running terminal in its resources), that counts as stopped
    • stop, saw_message (did the session's history take the message), own_address (the caller's session variable), launch_default (model and effort per tool, [launch.TOOL] in config.toml)
  • action log (src/log.rs): every amh run appends one line to amh-actions.log in the state folder: time, caller (Omnigent session, else tmux pane, else user), exit status, and the parsed command line; agent view and manager watch also get a line when they start
  • the agents of tasks
    • src/work.rs, pure: envelope; problem_of (an agent that is missing, error, stuck, or idle with open items; a blocked or retired task has none; a long_running task with blocked_on is not idle); the request, the first prompt, and the task record of a started agent; manager_message (tell manager: at most 600 characters, labelled with the task and blocked/done); close; tree; handover, relaunch, repointed (replacing a manager); the rows of agent list, agent problems, manager worktree
    • src/agents.rs, input and output: problems; start_task and start_agent (run each getagentsmd command with a 30 s limit, launch, record under the lock, send the first prompt, wait up to 90 s for it to show in the history); close_task; rotate
    • close_task, in this order: under the lock mark the task done and move its row to previous; email the human Closed ADDRESS on the task's thread, only when the human gave the task a request themselves (requests::from_human; the same rule decides whether a failed or stuck agent is reported to the human besides its manager); for an agent closing itself, print that its session stops and tell its manager; stop the agent
      • a failed email ends the action with the task done and the agent still running; a failed stop fails the action unless --agent-gone
    • rotate, in this order: read the old Omnigent session; start a fresh agent on the same task file with the same tool, model, effort, and folder unless options say otherwise; under the lock point the active tasks that reported to the old address at the new one, and for the main manager rewrite its address in env.toml; stop the old session; print the new address
      • a session field that is missing and not given as an option is refused before anything starts
    • a stopped session reads ready with runner_online=False, as a parked one does
    • unlike the earlier Python: --lines counts lines as sed does (line breaks are \n, \r\n, \r only)
  • mail (src/mail.rs): send on the tag's thread (agent Gmail account), list/trash the human's unread (human mailbox via himalaya config), fetch unread mail from one sender
    • pure: bare, tag_of, plan (subject and thread of an outgoing email from the emails found with the tag), compose (the bytes to send), parse (a raw email -> sender, headers, body, images), header_of, replace_reminder and Header::listed (what tell human and mail unread print)
    • input and output: Mailbox (one logged-in IMAP connection: search, unseen_from, mark_read), deliver (SMTP), send, unread
    • the watcher holds one Mailbox across polls and logs in again only after a failure
    • a stored email is read as Python's text mode read it: \r\n and \r become \n
    • compose + deliver build and send every outgoing email, to the human and to the guest
    • after a send, tell human lists the agent's earlier emails the human has not read and tells it to replace them with one email; left out of that list and of mail unread: item notices and watcher emails (X-AMH-From: amh ...), and emails sent outside amh (no X-AMH-From), such as a mail-compression job's summaries
    • an email to the human that Gmail refuses (e.g. the account's daily sending limit, 550 5.4.5) is kept in outbox/ in the state folder; the watcher tries the outbox every 10 min, oldest first, and stops at the limit; tell human then says the email is queued, and nothing it replaces is trashed
    • 🧑 "when we make agents add/remove pending items we urge them to do multiple at a time and also attach any other things to be emailed to the human. I don't want any additional latency from debounce"
      • 🧑 "hide the parts about pending item created or deleted"
        • todo add, todo done, task give record changes without bookkeeping emails
        • amh tell human --add ITEM --done ITEM records changes and sends only the supplied message
      • nothing waits: no record of unreported changes, no later digest
      • 🧑 "Get some agent to fix it, and don’t bother me afterwards": todo done ... --quiet-because WORDS removes the human's items without the email and notes the human's words in the task file
  • short commands for the human, top level (src/cli.rs, short in src/actions.rs)
    • 🧑 "sane short commands for common tasks like spawning agents, without needing tons of flags"
    • new NAME [GOAL] [-m] [-t TOOL] (current directory, Claude, reports to the main manager; editor when GOAL is missing), ls (= agent tree), view NAME [-r], say NAME [TEXT] (queued like an email from the human; editor when TEXT is missing), edit NAME, stop NAME (asks once when open items or undelivered messages remain, then clears them and closes), log NAME [-n N] (the task's lines from both logs, merged by time)
    • 🧑 "Make it lenient and allow both having and not having .md and allow full paths.": every value that names a task (task, who, source, to, task_file) goes through work::task_name: NAME, NAME.md, or a path; an omnigent:// address stays
  • blocked tasks: 🧑 "Make it s.t. agents naturally learn to use --on from running the help page command they would normally run"
    • the rule (WHAT is the thing waited for, never just who) and examples are in the help of the task group, of task status, and of --on; no instruction file says it
  • amh task edit TASK.md (the human): opens $VISUAL/$EDITOR/vi on a copy of the task file, like crontab -e
    • 🧑 "Should be append-only. If the edit changed other things, offer to reopen the file and ask for fix"
    • what was added at the end is stored as manager_mail/PREFIX-edit-EPOCH.txt and queued as a messages row, so it reaches the agent exactly like an email from the human
    • amh tell human --attach PATH attaches files (20 MB in total at most), e.g. a figure the human asked to see
    • crates: imap + native-tls (IMAP), lettre (building and SMTP), mail-parser (parsing), mime_guess (attachment type from the file name)
    • of a repeated header the first counts; the parser's own lookup would take the last, which the sender controls for Authentication-Results
    • an HTML-only body is kept as written, not converted to text
    • unlike the earlier Python: a found email's date is as written, not rewritten to one format; sent mail carries a Date header and the Message-ID <NANOSECONDS.PID@gmail.com>
    • checked read-only against the earlier Python on the live mailboxes: same search results, subjects, unread listing, and parse of real emails
  • watcher: loop every 5 s
    • src/decide.rs, pure: route, for_manager; plan (who gets a task's blocks), the message texts, settle (what follows from a delivery Outcome), without_marker; nudges (reminders and problem reports); chase (unanswered email)
    • src/watch.rs, input and output: the kept mailbox connection (Link), files, agents, the clock, the log; remembers only retry and reminder times between polls
    • log: one line per event on stdout, YYYY-MM-DD HH:MM:SS TEXT; an error is shown as its message
    • records in the records table of the database, named and formatted as the files the Python watcher wrote: amh-awaiting.txt (EPOCH TASK MAIL STAGE), amh-told-human.txt, amh-asked-human.txt, amh-problem-notices.txt, amh-compress.txt; reads the file amh-sent.txt
    • checked against the Python watcher on a frozen copy of the live root with the agents and mail replaced: same routing and delivery text of the 200 newest stored emails, same targets for every task file, same messages, log lines, notices, and records for deliveries, problem reports, and chasing
    • unlike the Python watcher
      • nothing is reminded or reported in a minute in which any agent's state could not be read; before, agents listed ahead of the unreadable one were still reminded
      • a failure to store guest mail keeps the mailbox connection; a failure to start the guest agent is logged, the process does not exit
      • a failure to write amh-awaiting.txt is logged and the delivered block's marker is still deleted, so the block is not delivered twice
      • a malformed row of amh-awaiting.txt or amh-sent.txt is dropped
      • stored human mail is created with mode 0600, not created and then restricted
    • mail intake: store, route, append the source line and insert a messages row (channel human) in one transaction, mark read
      • route: [tag] task; for manager/for a manager/for the manager starting or ending the body -> its manager; done task -> its manager; else main manager
      • stored file exists = already taken in
    • delivery: each pending human row and each (pending) block (text typed into a task file, guest mail) -> the task's agent; then the row is marked sent or the marker line is deleted, the source line stays
      • a block in a task with no agent (done or retired) goes to that task's manager, else the main manager
      • an idle Omnigent session whose runner is offline is reachable: the next message brings the runner back
      • stored email is inlined verbatim in <human_instruction>
      • 🧑 "if I message an agent and somehow they are not whatever state they are, I should get some sort of response"
        • delivery to the agent fails -> the block goes to its manager, else the main manager, and the human is emailed once that it was rerouted
        • nobody reachable -> the human is emailed once; retried after 10 min
        • delivered human email is recorded in amh-awaiting.txt; every email an agent sends is recorded in amh-sent.txt
        • no email from that task within 15 min -> the agent is reminded; within 30 min -> its manager is told and the human is emailed
        • 🧑 "No need to email me for this, just handle it": amh mail handled MAIL --because WORDS takes the caller's row out of amh-awaiting.txt and notes the human's words in the task file
        • human notices are sent once per message and outcome, keys in amh-told-human.txt
    • nudges: idle agent with open items is reminded at most every 30 min; the settings remind.idle_s and, per tool, remind.TOOL_idle_s (e.g. [remind] codex_idle_s = 300) change that
    • mailbox compression notice, every 5 min
      • 🧑 "do a dedicated agent “human manager” who directly gets notification to compress the mailbox and does it"
      • counts the unread emails from the agent account in the human's inbox, without the item notices (read-only)
      • more than unread_over: the compress.task agent is messaged with the count and the count per task tag; again hourly while it stays above, and at once when it reaches unread_urgent
      • amh-compress.txt holds the last notice (EPOCH COUNT); it is removed when the count is back at or under the threshold
    • agent problems (missing, error, stuck): each is reported once, naming the task file, tool, address, manager, and the harness error
      • to the task's manager; for error and stuck also by email to the human on the task's thread
      • mail for a missing or stuck agent goes to its manager instead
      • reported keys live in amh-problem-notices.txt; a problem that goes away is forgotten, so a recurrence is reported again
      • nothing is reported while Omnigent is unreachable
    • permission prompts: once a minute a stuck agent's permission prompts are answered yes and its manager is told
  • guest mailbox (src/guest.rs, pure; the watcher takes the mail in, src/actions.rs sends the answer)
    • mail whose From is exactly guest.address and whose first Authentication-Results has spf=pass and smtp.mailfrom= that address is stored under guest_NAME_manager_mail/ with its thread headers and up to 4 images of 10 MB each; other mail from that address is marked read and dropped
    • a dedicated agent (task guest_NAME.md, NAME from guest.name) is started under the main manager or reused; the email becomes its open item and a (pending) block
    • it answers with amh tell guest, which sends to the guest alone on the thread of the stored email

dropped on purpose from the old scripts

  • report envelopes/receipts: tell manager posts straight to the manager
  • recovery modes, v2 task format, audit watcher, mail compression
  • task record, mail trash-replaced: task give --from-human, tell human --replaces

About

amh: agent manager helper

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages