Skip to content

Latest commit

Β 

History

22 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

AeroMind

Agentic mission control for autonomous drone fleets. A multi-agent system that plans inspection flights over industrial sites, reads aerial imagery, answers operational-policy questions from a retrieval corpus, and commands physical aircraft β€” with a deterministic safety gate and a human authorisation step standing between the model and anything that moves.

Full stack: Python / FastAPI orchestration backend, Next.js / TypeScript operator console, streamed over SSE, traced end to end.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Next.js console  Β·  live agent trace  Β·  approval dialog  Β·  fleet map β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚ SSE  +  WebSocket
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          FastAPI  Β·  orchestration graph                β”‚
β”‚                                                                         β”‚
β”‚   recall ─→ route ─→ [ specialist ]* ─→ synthesize ─→ persist           β”‚
β”‚               β”‚                                                         β”‚
β”‚          supervisor        mission_planner Β· knowledge                  β”‚
β”‚          (routing only)    perception Β· analytics                       β”‚
β”‚                                    β”‚                                    β”‚
β”‚                            β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”                           β”‚
β”‚                            β”‚  SAFETY GATE   β”‚  deterministic, not a     β”‚
β”‚                            β”‚                β”‚  prompt                   β”‚
β”‚                            β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜                           β”‚
β”‚                    allow ──────────┼────────── block                    β”‚
β”‚                                    β”‚                                    β”‚
β”‚                         require_approval                                β”‚
β”‚                                    β”‚                                    β”‚
β”‚                            β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”                           β”‚
β”‚                            β”‚ HUMAN operator β”‚  fails closed on timeout  β”‚
β”‚                            β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜                           β”‚
β”‚                                    β–Ό                                    β”‚
β”‚   tools: fleet Β· airspace Β· weather Β· RAG Β· vision Β· sandboxed Python    β”‚
β”‚   memory: episodic + working + semantic (SQLite)                        β”‚
β”‚   tracing: span tree Β· token/cost accounting Β· LangSmith projection     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

What it looks like

Every screenshot below is from the system running with no API key β€” the deterministic provider drives it, so npm run screenshots reproduces these exactly on any machine.

A physical command stops for a human

The planner has drafted a mission and assigned an aircraft. It cannot launch. The operator sees the exact call, the reasoning that produced it, and a countdown that denies the request if it expires.

The approval gate

An unsafe command is refused outright, not escalated

Gusts at the refinery exceed the FB-Ranger-H6's envelope. The gate blocks it rather than asking a human to approve something unsafe β€” note that no authorisation was ever raised. The trace on the right shows the tool marked CRITICAL then REFUSED, with the specific rule that fired.

The safety gate refusing a launch

The run as a span tree

Every graph node, agent, guardrail check, tool call and model call, positioned by real start time. In a run that paused for an operator, the dominant span is hitl.await_approval β€” the human, not the model.

Trace timeline

Retrieval you can audit

Each passage shows its dense rank, its BM25 rank and the fused score, so a bad answer can be traced to a bad retrieval rather than blamed on the model.

Knowledge retrieval

Fleet view and a completed run

Fleet

Completed run


Run it

No API key is required. With no credential configured the system runs a deterministic simulated model provider β€” the graph, the tools, the safety gate and the approval queue are all the real implementations, and only token generation is stubbed.

docker compose up --build

Console at http://localhost:3000, API at http://localhost:8000/docs.

To route the same graph through a real model, put a key in backend/.env (already gitignored) and verify the live path before running anything larger. Two options β€” Gemini's free tier (no credit card) or Claude:

# backend/.env  β€” free option
AEROMIND_LLM_API_KEY=...
AEROMIND_LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
AEROMIND_LLM_MODEL=gemini-3.5-flash-lite

# or
AEROMIND_ANTHROPIC_API_KEY=sk-ant-...

One OpenAICompatibleProvider covers Gemini, Groq, GitHub Models, OpenRouter and Cerebras, so switching platform is two environment variables rather than a new client. Then:

python -m scripts.verify_live --mission

That makes one cheap call to confirm auth and model access, then optionally runs one full multi-agent turn, reporting tokens and dollars for both. A measured first call beats discovering the cost afterwards β€” a full mission turn is roughly $0.23 on claude-opus-5, $0.05 on claude-haiku-4-5, and $0 on Gemini's free tier.

Running without Docker
cd backend
python -m venv .venv && .venv/Scripts/activate      # Unix: source .venv/bin/activate
pip install -r requirements.txt
python -m scripts.generate_imagery
uvicorn app.main:app --reload
cd frontend
npm install
npm run dev

Deploy it

Two services: the API on Fly.io, the console on Vercel.

# API
cd backend
fly launch --no-deploy --copy-config
fly secrets set AEROMIND_LLM_API_KEY=...
fly deploy

# Console
cd ../frontend
vercel --prod
#   set NEXT_PUBLIC_AEROMIND_API=https://<your-app>.fly.dev in project settings
#   and add that origin to AEROMIND_CORS_ORIGINS on the API

Fly rather than a serverless host because this service holds connections open: the console streams the agent trace over SSE for the length of a turn, and a turn parked on a human approval produces no bytes at all for up to 90 seconds. That is exactly the traffic request/response platforms cut.

For the same reason the browser calls the API directly in production instead of going through Vercel's rewrite β€” every extra proxy hop is somewhere SSE events can sit and buffer. That is not theoretical: during development Next's default gzip did precisely this, and the approval dialog never appeared because the events were stuck in the compressor.

The deployed instance defaults to the simulator, on purpose. A public URL plus a free-tier key is a quota anyone can drain in two clicks. Live mode is a switch the owner flips:

fly secrets set AEROMIND_FORCE_SIMULATED=false   # demo
fly secrets set AEROMIND_FORCE_SIMULATED=true    # back to safe
What the free tier actually allows (measured, not quoted)

Running this against Gemini's free tier turned up limits that are not published on the docs page β€” they are only visible in your own AI Studio dashboard:

Limit Value Consequence
Requests per minute 5 One mission turn (~10 model calls) must pace itself
Requests per day 20 on gemini-3.8-flash ~2 mission runs per day
Transient 503s frequent "high demand" errors need their own retry path

The daily cap is the binding one, and it is why the deployed demo runs the simulator by default. gemini-3.5-flash-lite has a separate, larger budget and is the configured default.

Three provider-specific things had to be built to make the free path work at all, all of them in app/llm/client.py:

  1. A sliding-window pacer. Reacting to 429s is not enough at 5 rpm β€” a ten-call turn would spend most of its life in backoff. Requests are paced up front so a run is merely slow rather than a sawtooth of rejections.
  2. Two retry strategies, not one. A 429 carries the server's own retryDelay, so the right move is to wait exactly that long. A 503 is momentary oversubscription, where short exponential backoff clears it. Disabling the SDK's blind retry to get the first right made the second mandatory.
  3. Opaque provider metadata round-tripping. Gemini attaches an encrypted thought_signature to every function call and rejects the next request if it is not echoed back, in a non-standard field (tool_calls[0].extra_content.google.thought_signature). ToolCall carries it through the agent loop as a blob the loop never inspects.

Try these

In the console What it exercises
"Launch a grid survey over the solar farm to check for thermal defects" plan β†’ assign aircraft β†’ stops for a human β†’ launches on approval
"Dispatch a drone at the refinery right now for a perimeter patrol" gate refuses outright β€” gusts exceed the airframe limit
"What is the minimum visibility required to fly, and who decides?" grounded retrieval with inline citations
"Analyse the latest thermal capture from the solar site" vision model β†’ structured detections β†’ severity classification
"Rank the fleet by battery health" telemetry β†’ sandboxed Python, not prose arithmetic

The engineering, and why

Most of what is interesting here is not that agents call tools. It is what sits between the model deciding to act and the action happening.

The safety gate is code, not a prompt

A guardrail written into a system prompt is a guardrail the model can be argued out of. Every rule in app/graph/safety.py is deterministic Python that reads live fleet state and returns a verdict the model never sees until it is already decided.

Tools carry a risk tier. SAFE reads run freely. SENSITIVE writes are reversible and logged. CRITICAL commands move aircraft and cannot execute without a recorded human decision β€” regardless of how confident the model is.

Three properties fall out of that design, and each has a test:

  • Blocking beats escalating. A launch that violates a pre-flight condition is refused outright, never offered to an operator. Escalating an unsafe command launders a refusal into a decision someone can get wrong under time pressure.
  • Timeouts fail closed. An unanswered authorisation resolves to denied. Timing out into the permissive state is the single most common way a human-in-the-loop control is defeated in practice.
  • Autonomy level cannot override physics. Raising autonomy to autonomous removes the approval step, not the pre-flight checks.

Agents get a narrow tool surface

The perception agent reads untrusted image-derived text. It cannot see the launch_mission schema, and the check is enforced at the call site rather than trusted to the tool list in the prompt β€” a prompt-injected tool name would otherwise sail straight through. Retrieved documents are also screened for instruction-shaped text and downgraded to quoted evidence.

Retrieval, measured rather than assumed

Hybrid BM25 + dense retrieval over a synthetic operations corpus, fused by weighted reciprocal rank, with small-to-big indexing: narrow units are embedded and scored, whole sections are returned to the generator.

$ python -m scripts.eval_retrieval --ablate
corpus: 76 indexed units over 24 labelled queries

dense only            recall@1=0.792  recall@3=0.792  recall@5=0.833  mrr=0.802
lexical only (BM25)   recall@1=0.958  recall@3=0.958  recall@5=1.000  mrr=0.969
hybrid (RRF)          recall@1=0.958  recall@3=0.958  recall@5=1.000  mrr=0.967

Three findings from building that harness, all of which changed the code:

  1. Chunking was the bottleneck, not scoring. A six-item pre-flight checklist indexed as one blob diluted "visibility" across a passage six times longer than the rule itself, and no amount of scoring tuning recovered it. Splitting list items into their own retrieval units while returning the parent section moved recall@1 from 0.875 to 0.958 and MRR from 0.917 to 0.967, holding everything else constant.
  2. The canonical RRF constant of 60 is wrong for a small corpus. Tuned for TREC-scale collections, it flattens the score spread to nothing across a few dozen chunks. The constant now scales with corpus size.
  3. The hybrid does not beat BM25 here, and the ablation says so. A hashed TF-IDF projection is a lossy view of the same lexical signal, not an orthogonal one, so fusing them averages a strong ranker with a weaker copy of itself. The dense weight is set to 0.15 β€” enough to act as a recall backstop, not enough to overrule a confident lexical match. The eval queries also share the corpus's vocabulary, which flatters lexical retrieval; swapping in a sentence-transformer is a one-class change and the weight should be re-swept when that happens.

Reproduce any of it with --ablate, --sweep, --verbose.

Observability treats a run as a tree

An agent run is not a request, it is a call tree β€” supervisor β†’ specialist β†’ tool β†’ model. Flat logs cannot express that, so every unit of work opens a span with a parent pointer and its own token and cost accounting. The console renders the tree as a flame timeline; /api/traces/{id}/langsmith projects the same trace into LangSmith's run-ingestion shape.

The payoff is immediate in practice: in a run that paused for an operator, the dominant span is hitl.await_approval at 15.6 s against ~40 ms of model time. Knowing whether latency is the model or the human is the difference between tuning a prompt and re-designing an interface.

The simulator is a feature, not a mock

An agentic system that only runs with a funded API key is one nobody can exercise in CI, demo offline, or regression-test per commit. The simulated provider is deterministic and role-aware, so the 131-test suite asserts on routing decisions and tool sequences rather than model prose β€” including the full launch path: proposal β†’ gate β†’ approval β†’ hardware state change.

It also earns its keep as a bug-finder. It caught the code sandbox rejecting sorted(key=lambda …), which is the shape nearly every ranking snippet takes β€” an over-restriction that bought no safety and would have quietly degraded every analytics answer in production.

Memory is three things, not one

Episodic (the full transcript), working (rolling summary + last N turns, bounded by construction), and semantic (durable facts extracted from turns and retrievable across sessions). Compaction runs on the turns falling out of the working window rather than the whole transcript, so summarisation cost stays flat as a session grows.


Tested

$ pytest -q
131 passed

Concentrated where failure is expensive: test_safety_gate.py (18 cases β€” battery floors, wind envelopes, grounded packs, teardown intervals, airspace conflicts, prompt-injection screening) and test_hitl.py (approval, denial, timeout-fails-closed, decisions cannot be overwritten, a dead websocket cannot block a decision).

On the Claude integration specifically

A simulator that runs everywhere is also how a provider integration quietly rots β€” nothing exercises it, so nothing tells you when it breaks. test_anthropic_provider.py drives AnthropicProvider through the real anthropic SDK with a mock HTTP transport underneath. The SDK builds and validates the actual request, so the tests assert on the wire format β€” adaptive thinking, output_config.effort nested rather than top-level, cache_control on the system block, strict tool schemas, base64 image ordering, streaming above the timeout threshold β€” and on response parsing: tool_use blocks, thinking blocks kept out of the answer, cache-read token accounting, typed errors on 400 and 429.

That caught a real defect. The project was pinned to anthropic==0.69.0, which predates output_config entirely, so every live model call would have raised TypeError on a parameter the code passes unconditionally. Now pinned to 1.2.0 and covered.

What this does not claim: the tests prove the SDK accepts the request and the parser handles the documented response shape. They do not prove the live API accepts it β€” the system has only ever been run end to end on the deterministic provider. Set ANTHROPIC_API_KEY to exercise the real path.

Relatedly, get_llm() now raises ProviderUnavailable when a key is configured but the provider will not construct. It used to fall back to the simulator, which is the worst available behaviour: an operator who had configured a real model would receive canned reasoning about live aircraft with no indication anything was wrong.


Honest limitations

  • The code sandbox is not a security boundary. It is a restricted-namespace interpreter with a static AST screen and a wall-clock limit. It blocks the obvious escapes and would not stop a determined adversary. Production runs this in a network-less container; the interface is identical so the swap is a deployment change.
  • The fleet is simulated. FleetService is a real state machine β€” launching actually flips status and decrements battery β€” standing in for a vendor SDK.
  • The corpus is synthetic. The SOPs and type records were written for this project. They are not real regulatory instruments and are labelled as such in every document.
  • The dense embedder is a hashed TF-IDF projection, chosen so the system runs offline in CI. Its measured contribution on this corpus is near zero (above).
  • Traces live in memory with a bounded ring buffer plus disk export. A multi-instance deployment needs a real trace backend.

Layout

backend/
  app/
    agents/         supervisor, synthesiser, four specialists, shared loop
    graph/          orchestration state machine, shared state, safety gate
    llm/            provider abstraction: Anthropic + deterministic simulator
    rag/            chunking, hybrid store, embeddings, retrieval pipeline
    memory/         episodic / working / semantic memory over SQLite
    tools/          registry with risk tiers; fleet, airspace, RAG, vision, sandbox
    hitl/           approval queue
    observability/  span tracing, cost accounting, LangSmith projection
  scripts/          eval_retrieval.py, generate_imagery.py, verify_live.py
  tests/            131 tests
frontend/
  src/app/          console, fleet, traces, knowledge
  src/components/   agent stream, approval dialog, span tree, site map
scripts/            capture_screenshots.mjs (regenerates the images above)

API

Endpoint Purpose
POST /api/missions/run Run a turn, return the finished state
GET /api/missions/stream Same turn as an SSE event stream
GET /api/approvals Β· POST /api/approvals/{id}/decide Authorisation queue
WS /ws/events Live approval events
GET /api/fleet Β· /api/fleet/missions Fleet and mission state
POST /api/knowledge/search Hybrid retrieval, optional LLM rerank
GET /api/traces Β· /api/traces/{id} Β· /api/traces/{id}/langsmith Observability
GET /api/memory/facts Β· /api/memory/events Long-term memory

Built by Poreddy Narendra Reddy.

About

Agentic mission control for autonomous drone fleets: multi-agent orchestration, hybrid RAG, a deterministic safety gate and human-in-the-loop authorisation. Python/FastAPI + Next.js.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages