CONG HOANG “BILL” LE
About
I'm studying Robotics.
I'm Vietnamese.
I live in AUS and go to school in the US.
“Miệng luôn tươi cười, may mắn tự nhiên đến.”
Keep a smile on your face, and luck will find its way to you.
Projects
Six projects, two arcs: classical robotics built by hand, and agent infrastructure.
Phoenixbot: Real-Time Weed Detection for Autonomous Field Robotics
Olin Farm Robotics Challenge — 1st Place, 2024
Perceive, classify, and localize weeds in real time from a downward-facing depth camera, so an autonomous weeder knows exactly where to strike — in world coordinates, on a moving robot.
1. Problem Statement
Small-scale farms control weeds mechanically, not chemically — but hand-weeding doesn't scale, and blanket herbicide is exactly what these farms are trying to avoid. The Farm Robotics Challenge scenario: a robot drives over crop beds and must remove weeds without touching the crops.
That reduces to a perception problem with three hard constraints:
- Crop vs. weed discrimination at species level. Both are green plants in dirt. Distinguishing them requires identifying which species each green blob is — "plant vs. not-plant" is useless when the crop is lettuce and the weed is a weed.
- Metric localization, not just pixels. A bounding box on an image is useless to a robotic arm. The system must convert pixel detections into real-world coordinates in the arm's frame, through camera intrinsics, depth data, and hand-eye calibration.
- Real-time on embedded hardware. Detection runs continuously on a Raspberry Pi-class computer while the robot is moving. A 2-second-per-frame classifier means the robot has already driven past the weed.
My role on the team covered two stages — segmentation and metric localization: given a raw RGB-D frame, find every plant, isolate it as a crop-able region, then convert those pixel detections into real-world coordinates in the arm's frame. (Species classification was handled by teammates.)
2. Solution Architecture
┌──────────────────────┐
│ Intel RealSense │ color (1280×720 @30fps)
│ D435 (downward) │ depth (1280×720 z16, hole-filled)
└─────────┬────────────┘
│ realsense_frame (custom RGB+depth+intrinsics msg)
▼
┌──────────────────────┐
│ weed_detector node │
│ │
│ ① Segmentation │ HSV green mask → morphology → DBSCAN
│ (pixel space) │ → bounding boxes + centers + crops
│ │
│ ② Classification │ ResNet-18 (Pl@ntNet-300K weights)
│ (species) │ → species + confidence per crop
│ (teammates' work)│
│ │
│ ③ Localization │ deproject pixel + depth → camera frame
│ (metric space) │ → hand-eye transform → arm frame
└─────────┬────────────┘
│ weed_info (Vector3[] in arm frame)
▼
┌──────────────┐
│ Weeder arm │ actuates over detected weeds
└──────────────┘
Design decisions and why:
- Custom ROS 2 message (
RealsenseImage) instead of parallel color/depth topics: color, depth, and camera intrinsics must arrive atomically. A detection's 3D position is only meaningful if the depth pixels and intrinsics are from the same frame — separate topics guarantee eventual desync. - Classical CV for segmentation, learned model for classification. Every plant is green; HSV thresholding + morphological open/close is fast, interpretable, and tunable in the field (we carried HSV tuning sliders for lab vs. outdoor lighting). Species-level discrimination is where learning is actually needed — a ResNet-18 fine-tuned on Pl@ntNet-300K (1,081 species), matched against the known crops (
Lactuca,Carota) with a confidence fallback. - Depth-based deprojection, not homography. Using the RealSense intrinsics (
rs2_deproject_pixel_to_point) plus the aligned depth image turns any pixel into a metric 3D point, then a calibrated 4×4 hand-eye transform (camera_to_arm) maps camera frame → arm frame. The calibration constants were measured physically and validated with a ground-truth reference-frame test harness.
Pipeline (one frame):
- RealSense publisher captures color + depth, hole-fills the depth image, packages with intrinsics, publishes at 10 Hz.
weed_detectorruns segmentation → N bounding boxes with centers.- Each center is deprojected to camera-frame meters, then transformed to the arm frame.
- Each bounding box is cropped and classified; anything not a known crop is a weed.
- Weed positions are published on
weed_infofor the arm; an annotated overlay (labeled boxes: red = weed, tan = crop) is rendered for live debugging.
3. Implementation
Segmentation (my contribution)
The full chain in submodules/plant_id/segmentation.py:
- HSV green isolation —
cv2.inRangeover a hue band (outdoor-tuned: H∈[40,100], S≥30, V≥80), then morphological open (3×3, removes speckle noise from soil) and close (10×10, fills holes inside leaves), then hard binarization at the 150 level. - Point-cloud compression — the critical performance step. A 1280×720 binary mask produces up to ~921,600 candidate points; clustering that is wasteful because DBSCAN cost scales with point count, not image area. Instead, the binary mask is downscaled to a fixed budget of 10,000 pixels preserving aspect ratio, and only white-pixel Cartesian coordinates are kept. Cluster results are mapped back to full resolution by multiplying by the recorded scale ratio.
- DBSCAN over plant pixels —
eps=3,min_samples=3,n_jobs=-1on the compressed point cloud. Density-based clustering (rather than connected components) is what merges the leaves of one plant into a single cluster while keeping adjacent plants separate, and noise points (label = -1) are filtered before box extraction. - Bounding boxes + centers — per-cluster min/max extents with 10 px padding, mapped back to full-resolution coordinates; centers computed for the deprojection step.
- Cropping — each bbox is cropped from the original (full-res) frame, so the classifier receives full-quality inputs despite the compressed clustering stage.
The performance work
The naive version clustered the full-resolution point cloud. Two changes produced the 6× speedup:
- Resolution budgeting: DBSCAN ran on ~10,000 points instead of the raw binary mask's white-pixel count (typically 10–50× more).
- Single-pass geometry: points are converted to Cartesian once, clustered, scaled once on the way back out — no repeated intermediate copies.
Segmentation latency went from 0.3 s to 0.05 s per frame (6×) — fast enough that the pipeline runs at the 10 Hz publish rate with headroom for the classifier.
Testing & tuning harness (testing/)
Because there was no labeled dataset at the start, the repo ships its own evaluation infrastructure:
- IoU / precision / recall harness (
iou_helpers.py) comparing predicted boxes against hand-drawn YOLO-format ground truth, with YOLO→VOC box conversion viapybboxes - Interactive HSV tuner (
tune_hsv.py) — OpenCV trackbars over a live camera or saved image, for lighting-condition-specific thresholds - DBSCAN tuning (
tune_dbscan.py) — visual cluster feedback over parameter sweeps - End-to-end pipeline runner (
run_pipeline.py) — same segmentation → classification → localization chain as the ROS node, runnable on saved frames for regression testing - Reference-frame validation (
ref_frame_transform.py) — transforms calibrated against a 1-ft-interval reference sheet to quantify hand-eye calibration error
Localization (my contribution)
Pixel → world conversion uses the full RealSense intrinsics model (Brown-Conrady distortion coefficients published with each frame), depth in millimeters, and rs2_deproject_pixel_to_point. The camera-to-arm transform is a composed rigid transform (rotation about x by π for the downward mount, plus a measured translation offset), with the arm-frame output published directly as the actuator's target coordinates.
4. Results
- 1st Place, 2024 Farm Robotics Challenge — the pipeline ran live on the robot at the competition.
- 6× segmentation speedup (0.3 s → 0.05 s per frame) from point-cloud compression, with no loss in detection coverage — this was the difference between "demo that runs offline" and "system that runs on the robot while it moves."
- The IoU evaluation harness (predictions vs. hand-labeled ground truth) let the team tune HSV/DBSCAN parameters with data instead of eyeballing overlays.
- The package was merged into Olin-HAIR-Lab's shared robot codebase and reused by later teams.
What I took from it
This project is where I learned that robotics perception is rarely "train a model" — it's making a chain of deprojections, transforms, and thresholds robust enough that the model's output means something in the physical world. The unit that mattered most wasn't accuracy; it was meters.
CYPIU: Can You Pick It Up?
LLM-driven pick-and-place on a physical 6-DOF arm — my first end-to-end robotics build
Type "pick up the apple". The robot finds the apple, figures out where it is in 3D, solves for six joint angles, and grabs it.
Repo: github.com/titut/CYPIU · Successor project: ZenNav
1. Problem Statement
Household robots don't get drawers of labeled objects and fixed camera rigs. The question I set for myself: can a cheap hobby arm (myCobot 280, ~$700) + a Raspberry Pi + a webcam do language-directed pick-and-place on unstructured household objects?
That decomposes into four sub-problems, each of which is its own discipline:
- Language → intent. "Pick up the apple" must become
(action: pick up, object: apple)— robust to phrasing ("grab that apple", "get me an apple"). - Intent → pixels. "Apple" means nothing to a robot. The word must ground against an actual detection in the camera frame — and YOLO needs to actually see an apple, not a concept of one.
- Pixels → world pose. A detection is a 2D box. The arm needs a metric 3D position in its own frame, which means camera calibration, a fixed reference marker, and forward kinematics that match the physical arm.
- World pose → motor commands. Six joint angles that move the end-effector to the target — the inverse kinematics problem, including the cases where the target is unreachable.
I deliberately built this alone and on real hardware, because the gaps between "works in a notebook" and "works on a robot" are the actual education.
2. Solution Architecture
A ROS 2 package of six cooperating nodes, each owning one stage of the pipeline:
"pick up the apple"
│
▼
┌───────────────┐ GPT-4.1-nano parses free text into
│ cmd_gui │ {action, object} via the OpenAI API
└──────┬────────┘ (client node / pipeline orchestrator)
│ service calls
▼
┌───────────────┐ YOLOv4 (ONNX, 416×416, COCO classes)
│ obj_detection │ on the camera stream — resolves "apple"
└──────┬────────┘ to a bounding box
▼
┌───────────────┐ AprilTag (36h11, 55mm) detected on the
│apriltag_service│ workspace via tf2_ros + FK-derived arm pose
│ + ik + fk │ → object pose in the arm's base frame
└──────┬────────┘ → damped least-squares IK → joint angles
│ /joint_angles (Float32MultiArray)
▼
┌───────────────┐ pymycobot serial bridge → real motors
│ movearm │ publishes /current_angles back (feedback)
└──────┬────────┘
▼
┌───────────────┐ gpiozero AngularServo on GPIO 18
│ claw │ gripper open/close service
└───────────────┘
+ teleop (PS5 DualSense via pydualsense) — manual override
Key architectural choices:
- LLM as a parser, not a planner. GPT-4.1-nano converts commands to structured
{action, object}pairs; everything downstream is deterministic robotics code. This keeps the LLM's failure mode bounded (worst case: a mis-parsed noun), rather than letting it emit motor commands. - Service-based coordination (
cypiu_interfaces/Command,std_srvs/SetBool) between orchestrator and perception/actuation nodes — the pipeline is a request/response flow, not a stream. - Fixed AprilTag reference frame. Rather than solving full SLAM for a static workspace, a printed AprilTag anchors the world frame;
tf2_rostransforms the tag frame, and the object's position is resolved relative to it. Cheap, robust, and exact enough for tabletop manipulation.
3. Implementation
Kinematics from scratch
The core of the project — and where most of the debugging happened:
- Modeling: the arm is described in the product-of-exponentials (screw theory) formalism from Modern Robotics (Lynch & Park): a home configuration matrix
Mand six screw axesS_list, each measured off the physical arm's geometry. - Forward kinematics (
fk.py) computes the end-effector pose from joint angles — validated against the arm's own reported pose, which is how I caught (and fixed) axis-sign errors that no textbook warns you about. - Inverse kinematics (
ik.py): damped least-squares with joint-limit handling,
dθ = Jᵀ(JJᵀ + λI)⁻¹ · e, λ = 5×10⁻⁴
iterated to 10⁻³ tolerance over up to 200 iterations.
- Line search on the step size: every candidate θ + α·dθ is validated by running forward kinematics on it and checking the resulting pose error norm before acceptance (up to 10 backtracking trials). This keeps the solver from diverging near singularities — a detail that mattered constantly on a 6-DOF arm with a compact workspace.
- Joint-limit clamping: IK solutions are clamped against the myCobot's physical joint ranges (±168° for J1, etc.) before anything reaches the serial port.
Perception
- YOLOv4 → ONNX: weights exported to ONNX and served through
onnxruntime(CPU inference on the Pi — no GPU), 416×416 input, COCO classes.obj_detectionsubscribes to the camera with sensor-data QoS and overlays detections live. - AprilTag 36h11 via the
apriltag_roswrapper with per-tag geometry inconfig/tags.yaml; detections arrive as an array with relative transforms. - Calibration: camera intrinsics in
config/camera_info.yaml, plus a hand-written calibration workflow (examples/camera_calibration.py).
Actuation
movearmqueues incoming joint-angle messages and streams them to the myCobot over serial at/dev/ttyAMA0(1 Mbaud), publishing measured angles back at 10 Hz so the orchestrator sees the actual arm state, not just the commanded state.- The claw is a separate
gpiozeroservo node with pulse-width-calibrated open/close — deliberately isolated behind aSetBoolservice so gripper logic can't stall the motion pipeline. - PS5 (DualSense) teleop via
pydualsensefor manual recovery and demo control.
4. Results
It works end to end: spoken-or-typed commands → the arm picks up household objects that YOLO can recognize. Demo GIF slots below (to be captured from the live rig):
[demo GIF: command "pick up the apple" → detection → reach → grasp]
Honest results, as befits a first build:
- What works: the full pipeline executes reliably for COCO-class objects under consistent lighting; the LLM parsing stage is effectively 100% on well-formed commands; DLS-IK with line search converges across the arm's usable workspace; AprilTag anchoring gives repeatable world-frame poses.
- Where it breaks — and why that's valuable: objects YOLO can't classify (novel household items), IK targets outside the workspace (the solver correctly refuses rather than diverging — the line search sees the error floor), and ambiguous commands ("pick it up" with no referent). Each failure mode is a known failure mode, which is the difference between a demo and an engineering baseline.
- The deepest lesson: the hard parts were never the parts I expected. The LLM integration took an afternoon. Making FK match reality, calibrating the camera-to-arm transform, and surviving serial-latency feedback loops took weeks. Robotics is where your code meets physics at the last millimeter.
Why this project exists
Beyond the demo itself, building on ROS 2 end-to-end showed me exactly what the framework provides (message plumbing, TF, launch) and what it costs (codegen ceremony, QoS semantics I didn't control, launch-stack opacity). That diagnosis is the direct origin of ZenNav — where I rebuilt the same ideas (pub/sub autonomy stack, layered safety, session replay tooling) by hand on Zenoh.
CYVLA: Teaching a Cobot Arm to Charge a Phone
Fine-tuning a 7B Vision-Language-Action model on up to 1,000 human demos of a task robots are famously bad at: precise insertion.
Planned project — writeup describes the design, not results. Take a commodity cobot arm, fine-tune OpenVLA 7B on up to 1,000 human demonstrations of plugging a charger into a phone (RGB-D, RealSense), and get an end-to-end policy that goes from camera pixels to a successful charge — no hand-written perception, no hand-tuned IK staging.
Honest expectations up front: I expect this to mostly fail. Contact-rich insertion without force feedback is at the research frontier; a 7B generalist policy fine-tuned by one person on a hobby arm has every reason to score near zero. I'm doing it anyway — the value is in the measured gap between human-trivial and robot-possible, the dataset itself, and the scaling curve, not the success rate. Status: data collection rig in design. This page will be rewritten with real numbers as phases complete.
1. Problem Statement
Everything I've built so far — CYPIU, ZenNav — shares the same skeleton: a classical perception stack feeds a classical planner. It works, but each stage (detector, pose estimator, planner) is a hand-engineered component with hand-tuned handoffs. VLA models promise to collapse that pipeline into one policy: camera frames + language instruction → robot actions, learned end-to-end from demonstration.
I want to test that promise on a task that's deliberately hostile to the classical pipeline:
Charging a phone — insert a charging cable's plug into the phone's port.
Why this task specifically:
- Contact-rich and precision-critical. The plug must enter a slot with millimeter-scale tolerance, through contact forces that a pure position controller will fight. Insertion tasks are the canonical hard case for visuomotor policies.
- Deceptively simple for humans. A person does it in two seconds while barely looking. The gap between "trivially demonstrated" and "trivially learned" is exactly what I want to measure.
- A real, closed-loop success criterion. The phone either charges or it doesn't. No proxy metrics — the task grades itself.
- Bounded dataset size. 1,000 demos is large for a solo project, small by foundation-model standards — which makes the interesting question concrete: how far does fine-tuning a pretrained 7B VLA get you with one camera and one task?
2. Solution Architecture (planned)
┌────────────────────────────────────────────────────────────┐
│ DATA COLLECTION │
│ human demonstrator + cobot arm (kinesthetic guidance) │
│ RealSense RGB-D @ fixed wrist/scene mount │
│ + arm proprioception logged in lockstep with frames │
│ → (image, language, action) triples → RLDS dataset │
└─────────────────────────┬───────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ FINE-TUNING (OpenVLA 7B) │
│ LoRA / QLoRA on the OpenVLA checkpoint │
│ input: 224×224 RGB + "plug the charger into the phone" │
│ output: discretized action tokens (ΔEE pose + gripper) │
└─────────────────────────┬───────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ DEPLOYMENT LOOP │
│ RealSense frame → policy → action chunk → cobot arm │
│ → next frame … (closed loop until insertion or timeout) │
└─────────────────────────────────────────────────────────────┘
Planned stack:
- Policy: OpenVLA 7B — open-weights VLA (Llama-2 backbone + fused DINOv2/SigLIP vision encoders), pre-trained on 970k Open X-Embodiment episodes across hundreds of robots. Fine-tuned, not trained: the value is starting from generalist robot knowledge.
- Robot: a cobot arm with position-controllable joints and gripper (myCobot 280 class — already characterized in CYPIU). Low payload is acceptable: a phone + cable is ~200 g.
- Camera: RealSense RGB-D, a fixed third-person mount covering the workspace — chosen over a wrist cam for v1 to keep the visual distribution consistent across 1,000 demos.
- Action space: OpenVLA's discrete action tokens (256 bins per dimension) over end-effector deltas + gripper open/close, at a fixed control frequency.
- Fine-tuning: LoRA/QLoRA on a single 24 GB GPU (quantized base weights), batched over the RLDS-converted dataset.
The critical data decision — and the honest risk. Human demonstrations filmed with only a camera don't come with action labels; OpenVLA learns (image, instruction) → action, so every frame needs the robot's action too. The plan is kinesthetic teaching: a human physically guides the cobot through the insertion while the arm logs its own joint/EE state, so proprioception is the action label — recorded in lockstep with the RealSense frames. Filming a human hand doing it (no arm labels) is the tempting shortcut and the known trap; it's explicitly out of scope for v1.
3. Implementation Plan
Phase 0 — Rig (week 1–2) - Mount arm + RealSense; fix workspace geometry; lock camera exposure/white balance. - Synchronized logger: RealSense frames + arm proprioception + gripper state → timestamped episodes, RLDS-ready from day one. - Pilot 50 demos to find the failure modes of the data collection itself (workspace occlusions, reset procedure consistency, demographic variance in how people guide the arm).
Phase 1 — Dataset (weeks 2–6) - 1,000 demos across ≥ 3 demonstrators, varying: phone position (a grid over the workspace), phone orientation, cable approach direction, lighting. Every episode: reset → language command → guided insertion → success/failure label. - Deliberately log failures too — a small fraction of failure episodes may be more valuable to the policy than a perfect set, and success/failure metadata enables filtering experiments later. - Dataset hygiene: dedupe, verify proprioception-frame sync (drift is the silent killer of VLA fine-tunes), train/val split by demonstrator not by episode (so validation measures generalization to new humans).
Phase 2 — Fine-tuning (weeks 4–7, overlapping) - Baseline first: zero-shot OpenVLA on the task, recorded as the number to beat. (Expectation: near-0% — the base model has never seen this plug; documenting that matters.) - QLoRA fine-tune on the collected episodes; hold out a validation demonstrator; track validation success rate, not training loss. - Ablations as compute allows: episode-count scaling (the sample-efficiency curve is arguably the most publishable artifact of the project).
Phase 3 — Evaluation & deployment - Fixed protocol: N rollouts per condition (phone at grid positions × orientations × lighting), success = charge detected at the port. - Failure taxonomy on rollouts: approach error, insertion slip, premature gripper release, oscillation at contact — the same instinct as Automation Forge's failure taxonomy, applied to a physical policy. - Closed-loop vs. open-loop comparison (re-plan every step vs. execute a predicted chunk) — insertion tasks are where the difference should show.
The milestone ladder — where the point of no return is
The full 1,000-demo run is the stretch goal, not the commitment. The project is structured so each milestone is informative on its own:
- Milestone 1 (committed, ~3–4 weekends): rig + 50–100 demos + zero-shot baseline + one small QLoRA fine-tune. This answers the only question that matters: does fine-tuning on my data move the success rate at all? Everything after this is optional and nothing before it is wasted — a "yes" justifies scaling; a "no" is the most interesting negative result a portfolio can hold.
- Milestone 2 (stretch): scale to 1,000 demos, produce the data-scaling curve.
- Milestone 3 (stretch): closed-loop comparisons and out-of-grid generalization.
4. Expected Results & Success Criteria
Expected outcome: poor — stated plainly, on purpose. The realistic prediction is that the fine-tuned policy performs meaningfully above the zero-shot baseline but well below reliability — low single-digit to low double-digit success percentage on the hardest contact phase, with most rollouts failing at the insertion. If I'm wrong in either direction, the result is worth publishing on this page; if I'm right, the artifact is the measured gap, the dataset, and the failure taxonomy.
Targets (to be replaced by measured numbers):
- Primary (Milestone 1): fine-tuned policy measurably above the ~0% zero-shot baseline — any statistically real lift from 50–100 demos validates the pipeline.
- Secondary (stretch): data-scaling curve (50/300/1000 episodes); generalization to a phone pose outside the training grid; one demonstrator held out entirely. The 60%-success bar for the full run is an aspiration, not a promise.
- Deliverables (guaranteed regardless of policy performance): public RLDS dataset (RGB-D + proprioception episodes), fine-tuned checkpoints, evaluation harness, failure taxonomy — and a writeup of whatever the numbers turn out to be.
Known risks, ranked:
- Contact dynamics dominate. VLAs see pixels, not forces; insertion near the slot may stall. Mitigation if needed: visual servoing assist around the final centimeter, or an arm with force sensing.
- 7B inference latency on available hardware — if the loop runs at 2 Hz, the policy must rely on slower, more deliberate motions (which kinesthetic data can encode).
- Demo distribution collapse — 1,000 near-identical trajectories teach memorization, not robustness. The diversity protocol in Phase 1 exists for this.
- Label noise from kinesthetic teaching — human guidance is shaky; smoothing/filtering pass planned before training.
Why this project
Every project so far built a piece of the classical autonomy stack by hand — this one tests the thesis that's currently reshaping the field: that a sufficiently general model, fine-tuned on enough task data, replaces the pipeline. Whether it works or fails measurably, the result is the most interesting thing I'll have built.
AgentHost: A Modular Host for Folder-Based LLM Agents
One Python host, many agents — each a self-contained folder with a prompt, tools, skills, memory, and a schedule.
Not a framework you configure — a host where an agent is a folder. Drop a directory with a system prompt and some Python functions into
toolboxes/, and it's live: chat with it from a terminal UI, an HTTP API, Discord, Telegram, or WhatsApp. It remembers conversations, keeps its own schedule, and can delegate to other agents.
Repo: github.com/titut/agenthost · Companion project: Automation Forge
1. Problem Statement
Agent frameworks in the wild tend to fail in one of two directions: heavyweight platforms that lock your agent's brain behind their API, or notebook-grade scripts that evaporate on restart. I wanted a personal agent infrastructure with a different center of gravity:
- The agent is a folder. A whole agent — identity, tools, knowledge, schedule, memory — should be inspectable and versionable as plain files and Python functions. No YAML schema that grows until it's a programming language, no external state I can't read.
- Lifelong memory on a real budget. LLM context windows are lossy and paid for by the token. The agent needs to remember — verbatim recent context, semantic retrieval of the distant past — backed by a boring, reliable SQLite file per agent.
- Capability without context rot. Real agents accumulate toolsets (research, finance tracking, documents, email, scheduling) far too large to keep in the system prompt at once. The host must let agents swap capability sets at runtime without losing identity or memory.
- Presence. An agent that only exists when I open a terminal isn't an assistant. It needs interfaces where I already am (chat platforms), and autonomy (scheduled events, self-managed triggers) independent of any client.
The design bet: most agent frameworks over-abstract. If the unit of an agent is a folder, everything else — hosting, interfaces, memory, scheduling — becomes small, replaceable plumbing.
2. Solution Architecture
┌─────────────────────────────┐
│ Agent (core) │
│ OpenAI-compatible LLM loop │
└──────────────┬──────────────┘
┌───────────────┬───────┴────────┬───────────────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐
│ Memory │ │ Toolbox │ │ Planner │ │ Events │
│ SQLite + RAG │ │ discovery │ │ DAG multi- │ │ APScheduler │
│ per agent │ │ (runtime │ │ step plans, │ │ (agent-managed│
│ │ │ switching │ │ step-by-step │ │ cron, YAML) │
└──────────────┘ └─────────────┘ └──────────────┘ └───────────────┘
▲ ▲ ▲ ▲
└───────────────┴───────┬────────┴───────────────────┘
│ built-in + toolbox tools
┌──────────────┴──────────────┐
│ Interfaces │
│ Textual TUI · CLI (--once) │
│ FastAPI + SSE server │
│ Discord · Telegram · WhatsApp│
└─────────────────────────────┘
An agent is a directory:
agents/finance/
├── WHOAMI.md # system prompt — the agent's identity
├── CRITICAL.md # high-priority instructions injected above everything
├── agent.yaml # model, memory, built-in tools, secrets, bridging config
├── tools/ # Python functions → LLM tool schemas (auto-discovered)
├── skills/ # Markdown knowledge snippets, loaded on demand via get_skill
└── events.yaml # scheduled triggers (daily/interval/one-shot, human-readable)
Key design decisions:
- Toolbox switching instead of tool sprawl. Toolsets live in composable toolboxes — directory bundles of
tools/(functions) andskills/(Markdown). A finance agent swaps to theresearchertoolbox mid-conversation to browse the web, then switches back, keeping thread state and memory intact. Toolbox state is per-thread, so two conversations with the same agent can hold different toolsets. Only built-ins persist across switches. - Skills as lazy-loaded Markdown. Skill summaries sit in the system prompt; full content is fetched via a
get_skillbuilt-in only when relevant. Knowledge scales without eating the context window. - Secrets outside the codebase. API keys live in a KeePass
.kdbxdatabase, loaded into environment variables at runtime — nothing sensitive inagent.yamlor git. - Model-agnostic. The
openaiSDK pointed at any OpenAI-compatible endpoint (default DeepInfra; Ollama/vLLM work for everything), with reasoning-effort and penalty controls.
3. Implementation
The agent loop (agent.py, ~1,100 lines)
Async OpenAI streaming loop with full tool-calling support: tool schemas are generated from Python functions by signature inspection, executed through a ToolRunner, and results fed back until the model produces a final answer. Context assembly layers, in order: agent identity (WHOAMI.md), critical instructions, skill summaries, active toolbox tools, current datetime grounding, then RAG-recalled memory. Streaming responses propagate to every interface via async generators.
Memory with RAG (memory.py, ~700 lines)
Per-agent SQLite database with three tiers of recollection:
- Recent window — recent messages stored verbatim, replayed into the prompt.
- Semantic retrieval — older conversation content is chunked, embedded, and retrieved by cosine similarity against the current query, so week-old context resurfaces exactly when relevant. Embeddings via
bge-m3through any OpenAI-compatible endpoint. - Key/value store — for durable facts the agent explicitly wants to keep.
The retrieval pipeline is the same one exposed to the researcher toolbox: chunk → embed → top-k cosine similarity.
Planning (planning.py)
Agents can create structured multi-step plans as a DAG: plan(action="create") builds steps with dependencies and optional per-step toolbox assignment; plan(action="next") executes exactly one step as a focused agent.chat() invocation. Step results are persisted to a task-state database (with statuses: pending/running/completed/failed/skipped/blocked) and injected into later step prompts — so a ten-step task survives context limits, crashes, and thread switches.
Scheduled events (events.yaml + APScheduler)
Instead of cron syntax, schedules are structured YAML (daily, weekday, day-of-week, interval, one-shot; multiple times per trigger; timezone-aware via ZoneInfo) validated by Pydantic. Agents manage their own triggers through the event_tool CRUD tool, and the scheduler hot-reloads when the YAML changes. An agent can decide, mid-conversation, to remind itself to do something tomorrow.
Interfaces
- Textual TUI (
chat_tui.py, ~1,400 lines): streaming chat, tool-call/result cards, thread picker,@pathfile attachment (with transparent text extraction from.docx/.pdf/.xlsx/.csv), and a chatless monitor mode for watching scheduled activity. - CLI: one-shot queries (
--once) for scripting, plain-text loop mode, thread monitoring. - FastAPI + SSE server: streaming HTTP API for any client.
- Bridges: Discord (mention/prefix triggers, access control, attachments, message edit), Telegram (polling, access control), WhatsApp (Node.js/Baileys bridge with QR pairing).
The toolboxes
Shipped as working examples of the model: finance (18 tools — double-entry ledger, budgets with threshold checks, trend analysis, exchange rates, encrypted backup), researcher (DuckDuckGo search + crawl4ai fetching, chunked and embedded into a local vector store with bge-m3, budgeted query plans), document_writer (DOCX/XLSX/CSV/Markdown generation), gmail, and todo.
4. Results
- In daily personal use as my own assistant host — the finance, research, and document toolboxes run as real tools, not demos.
- One host, six interfaces, no per-agent glue code: the same folder-based agent is reachable from terminal, HTTP, and three chat platforms. Adding an agent is adding a folder.
- Memory that survives: SQLite + RAG retrieval recovers context far outside the context window — the agent references conversations from weeks ago when they're relevant, at zero marginal token cost for old messages.
- ~8,400 lines of core code doing what multi-thousand-line frameworks do, with every abstraction earned rather than speculative.
What I took from it
The interesting engineering in agents isn't the model call — it's everything around it: what to put in the system prompt, what to retrieve and when, how to bound a tool's blast radius, and how to give an agent agency (schedules, planning, toolbox switching) without giving it fragility. Building the host taught me more about where LLM agents actually fail than any framework tutorial could — and the framework-sized lessons (bounded toolsets, validated schemas, durable memory) are exactly what Automation Forge optimizes next.
Automation Forge: Autonomous Optimization of LLM Agent Configurations
Treat the agent — its prompt and tool schemas — as an optimizable artifact, and close the loop with no human inside it.
You have an agent — a system prompt and a set of tool definitions — and a fixed set of test cases with expected outputs. Automation Forge runs the loop for you: execute every case, judge the failures, diagnose why they failed, apply targeted edits to the prompt and tool descriptions, and repeat — until quality thresholds are met or a budget is spent. No human in the loop.
Repo: github.com/titut/automation-forge · Born from: AgentHost
1. Problem Statement
While running live agents on AgentHost, the bottleneck stopped being the model and became the configuration: a system prompt with one ambiguous sentence, or a tool description with one ambiguous parameter, would produce large, inconsistent variance in output quality. The standard fix — hand-editing the prompt, rerunning tests, eyeballing the difference — is slow, unrepeatable, and throws away the reason each edit worked.
The problem statement: given a starting configuration (system prompt + tool schemas) and a fixed set of user-provided test cases, autonomously produce a configuration that passes them reliably.
Three constraints shaped the entire design:
- No human in the loop. The user provides inputs and the oracle (expected results) up front; everything between is autonomous. Human review happens before and after, not during.
- The optimizer must not be able to break what it's fixing. The obvious failure mode of prompt-optimization loops is a rewrite-happy editor that improves one case while silently regressing three others. Edits must be structured and permissioned, not free-text.
- A pass is not a pass. LLMs are non-deterministic. A configuration that scores 100% once is not proven — the loop must demand statistical evidence before declaring success.
2. Solution Architecture
User provides (once): The optimization loop:
┌─────────────────────┐
│ system_prompt.md │ ┌──────────────────────────────────────┐
│ tool_defs.json │ │ ① Test Harness │
│ cases.json │───▶ │ run config against every case │
│ config.yaml │ │ → output + reasoning + tool calls│
└─────────────────────┘ └──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ ② Evaluator (LLM) │
│ pass/fail → failure class from │
│ a fixed taxonomy → diagnosis │
│ → proposed structured edits │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ ③ Loop Controller │
│ aggregate scores · check budgets │
│ stop thresholds · pick failures │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ ④ Editor (permission-gated) │
│ 6 whitelisted operations, │
│ validated → new version │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ ⑤ Version Store │
│ every config + scores persisted, │
│ best-so-far always recoverable │
└──────────────────────────────────────┘
Design principles (from SPEC.md, enforced in code):
| Principle | Meaning |
|---|---|
| Fixed prompt, no accumulating history | The artifact is one system prompt + tool schema set. No synthetic conversation history accumulates between runs — every case runs against the same configuration, so improvements are attributable. |
| User owns the oracle | Expected results come from the user; the system never guesses correctness. |
| Structured edits only | The editor cannot rewrite anything as free text (see below). |
| Version everything | Every evaluated configuration is stored with its scores; best-so-far is always recoverable. |
| Budget-driven termination | The loop stops on threshold pass, no-improvement, or exhausted budget (iterations / tokens / time / cost). |
The six edit operations
The evaluator proposes edits, but can only speak in this grammar — each permission-gated per configuration:
edit_system_prompt— replace the prompt or one marked sectionadd_constraint— append a ruleedit_tool_description— rewrite a tool's top-level descriptionedit_parameter_description— rewrite one parameter's descriptionrename_tool_display— change display name, never the function bindingedit_tool_schema— structural change, disabled by default
Every edit carries a reason string and is validated (Pydantic + permission check) before application. Notably, most gains come from the cheap, safe edits: clarifying a tool description or parameter docstring fixes tool-misuse failures without touching the prompt at all.
3. Implementation
The evaluator: a doctor, not a grader
The evaluator LLM receives the full context — current prompt, tool schemas, the case input, the expected-result spec, the agent's actual output, its reasoning trace, its tool calls — and must return strict JSON: pass/fail, a failure class from a fixed taxonomy (missing_step, wrong_format, tool_misuse, hallucination, ignored_constraint, ambiguous_instruction, missing_context, over_complexity), a diagnosis citing specific wording, and ≥ 1 proposed edit for every failing case (enforced — an empty edit list on a failure is invalid output).
Scoring without an LLM oracle (metrics.py)
Deterministic scoring keeps the ground truth trustworthy: exact (normalized text), contains / contains_all (partial credit), json_match (parse + path check), and custom evaluators. The weighted score combines case pass rates with per-case weights — so the loop optimizes what the user actually specified, judged by code, not by another LLM's opinion.
Regression gating — the part most optimizers skip
This is the feature I'd keep if I kept only one:
- A configuration that reaches the single-run thresholds isn't accepted yet. Each test case is re-sampled
samples_per_casetimes. - Success requires both the overall pass rate and every individual case's pass rate to clear configured minimums (
min_pass_rate,min_case_pass_rate). - If regression fails, the loop continues optimizing — and the regression results are persisted (
version_NNN_regression.json) as evidence.
This directly attacks the non-determinism constraint: "it passed once" is not a claim this system is willing to make. The same gate is available standalone via forge regression <run> --samples N — a post-hoc confidence check for any configuration, optimized by Forge or by hand.
Runs are durable artifacts
Every run is a directory with a manifest.json (version history, best-version id, status), per-version case results, and regression records. forge resume continues an interrupted run; forge show-best recovers the winning configuration; budgets mean an unattended overnight run can't bill a surprise. The CLI (forge run / optimize / resume / show-best / regression) is built on click with rich output; every role (agent/evaluator/editor) can point at a different OpenAI-compatible endpoint.
Where it came from
Automation Forge exists because AgentHost made the failure modes visible: watching real agents misuse tools because a description was vague, or ignore constraints buried mid-prompt. The taxonomy in the evaluator is literally the list of failures I watched live agents commit. It optimizes AgentHost-style configurations — which is why tool_defs.json is plain OpenAI tool-schema format and tool implementations are ordinary Python TOOL_HANDLERS.
4. Results
- The loop works end to end, unattended: on the bundled
research_taskexample, an autonomous run took a configuration that failed cases, diagnosed them (tool misuse and format issues), applied targeted edits — and reached the full pass threshold, with the regression gate then re-sampling every case before declaring success. Real run artifacts (manifests, per-case results, regression records) ship in the repo. - Structured edits demonstrably outperform free-text rewrites in the failure modes that matter: diagnosing
tool_misuseand fixing one parameter description is a surgical, reviewable change — something hand prompt-tuning rarely achieves. - Model-agnostic by construction: the whole loop runs against local endpoints (Ollama/vLLM), making optimization iterations cheap and private.
- Built tested: harness, evaluator, editor permissions, regression gating, and fallback edits all have dedicated test modules.
Honest scope
The current evaluator is single-pass per case, and the oracle modes are exact/match-based rather than rubric-based — "did the agent actually reason well" is out of scope by design (the user owns the oracle). The roadmap is about statistical confidence, not capability: more samples, per-class diagnosis depth, and smarter failure-selection heuristics for the next iteration's edits.
What I took from it
Evals are not a step after building an agent — they're the only way to change an agent without superstition. After building this, I can't unsee it: every prompt tweak is an uncontrolled experiment unless there's a fixed case set, a deterministic judge, and a regression gate. The loop is small; the discipline it enforces is the whole game.
Resume
EDUCATION
WORK EXPERIENCE
- Supported the Amazon Leo program: implemented test procedures, set up test stations for line bring-up, production debugging across cross-functional teams.
- Scoped and developed internal tool infrastructure using CloudFormation IaC (Lambda, Cognito, DocumentDB, S3, API Gateway) with CI/CD in a team of two.
- Architected rental property platform from 0 to MVP as sole engineer: Django REST/PostgreSQL with Twilio OTP, Didit KYC, Dropbox Sign e-signature, MTN/Airtel mobile money webhooks, credit scoring.
- Built mobile-first React/TypeScript tenant SPA wrapped as native Android app (Capacitor).
TECHNICAL EXPERIENCE
- Implemented plant/weed segmentation using DBSCAN clustering with bounding-box detection; compressed image point-cloud matrices via dimensionality reduction, decreasing latency 0.3s → 0.05s per frame (6× speedup).
- Led 10-person interdisciplinary team conducting 50 interviews with blind/low-vision individuals; synthesized findings into actionable design insights.
- Facilitated weekly office hours and graded assignments for 60+ students across three core mathematics courses.
SOLO PROJECTS
- LLM-driven Robotic Arm — OpenCV/YOLOv4 detection, AprilTag 5-DOF pose estimation, damped least-squares IK with line search; end-to-end CLI→LLM pick of household objects.
- Zenoh Robotics Stack — replaced ROS 2 with a Zenoh pub/sub stack (8 decoupled nodes), typed versioned wire schema, MCL + Gauss-Newton ICP localization, footprint-aware A*.
- Automation Forge — autonomous LLM-agent tuning pipeline: test harness, failure-taxonomy evaluator, permission-gated editor, regression gating, versioned rollback.
LEADERSHIP
- Managed $8,500 budget to plan and execute 20 volunteering events; raised $20,000 for community initiatives.
SKILLS
Languages: C, Python, TypeScript · Frameworks: Django REST, React, ROS 2 · Tools: AWS (Lambda, DocumentDB, S3, API Gateway, CloudFormation), PostgreSQL, Redis, Git, Gazebo, Linux