Research into evolving faux-foundation's agent architecture using Dapr actors, informed by pi-agent-core's design patterns.
The chat UI runs the entire agent loop in the browser (Chat.tsx):
- Builds input items from conversation history
- POSTs to llm-proxy
/v1/responses(streaming SSE) - Parses tool calls from SSE events
- Executes tools against the tool-service via Dapr service invocation
- Appends results to input, re-submits to LLM
- Loops until the LLM responds with pure text (no tool calls)
- Persists to Dapr state store after each completed exchange
MacGyver runs as a separate bespoke agent with its own loop (star-poller → clone → reverse-spec → commit). There is no shared agent runtime between the chat UI and MacGyver.
Browser (Chat.tsx) MacGyver (agents/macgyver/)
│ │
├─ POST /v1/responses (stream) ├─ setInterval → star-poller
├─ parse tool calls ├─ clone repo
├─ POST /tools/web-fetch ├─ CopilotClient.sendAndWait()
├─ append results, loop ├─ commit spec
└─ persist to state store └─ (no events, no steering)
pi-agent-core (from badlogic/pi-mono) is a lightweight agent orchestration layer built on top of pi-ai (a unified LLM API). Key patterns:
- Two-loop design: Inner loop handles tool calls + steering. Outer loop handles follow-ups after the agent would otherwise stop.
- Event system: 10 event types across agent/turn/message/tool lifecycle for real-time UI updates.
- Steering & follow-up queues: Mid-run injection ("stop, do this instead") and post-run injection ("also do this when done") with configurable drain modes.
- Before/after tool hooks: Safety gates before execution, result modification after. Block dangerous calls with a reason.
- Parallel tool execution: Sequential preflight (validation + beforeToolCall), concurrent execution, results emitted in source order.
- Custom message types: App-specific messages in the transcript alongside LLM messages, filtered before LLM calls via
convertToLlm. - Context transformation pipeline: Two-phase —
transformContext(prune/inject) →convertToLlm(filter custom types) — runs before each LLM call. - Single dependency, five source files: Agent class, agent-loop, proxy, types, index.
copilot-llm-svc is faux-foundation's equivalent of pi-ai — a local OpenAI-compatible proxy routing to GitHub Copilot's LLM API. Key capabilities in pi-ai that llm-svc could adopt:
- Cost/usage tracking — token breakdown and per-request cost calculation
- Thinking/reasoning support — unified thinking levels mapped to model-native mechanisms
- Context overflow detection — pattern-matching across provider error formats
- Prompt cache control —
cacheRetentionandsessionIdfor session-aware caching - Abort with partial preservation — clean cancellation keeping partial content
- Streaming JSON parsing — safe partial tool-call argument parsing mid-stream
Instead of building a custom Agent class with state management, single-run enforcement, HTTP endpoints, and event delivery — use Dapr actors. Each agent conversation becomes a virtual actor instance.
| pi-agent-core concept | Dapr building block |
|---|---|
| Agent instance state | Actors — virtual actor per session, state auto-persisted, single-threaded |
| Single-run enforcement | Actors — one call processed at a time, guaranteed by runtime |
| Event system | Pub/Sub — agent publishes to topic, UI and other agents subscribe |
| Steering / follow-up queues | Actor methods — steer() / followUp() called on the actor |
| Triggers | Input Bindings + Jobs API — cron, webhooks, event-driven |
| Tool execution | Service Invocation — agent actor calls tool-service via Dapr (already exists) |
| Context persistence | State Store — actor state manages conversation (already exists) |
┌────────────────────────────────────────────────┐
│ Dapr Actor: AgentSession │
│ (one per conversation) │
│ │
│ Methods (replace custom endpoints): │
│ prompt(text) → starts the loop │
│ steer(text) → injects mid-run │
│ followUp(text) → queues for after │
│ abort() → cancels current run │
│ getState() → returns messages/status │
│ │
│ Actor guarantees: │
│ • one call at a time (no concurrent runs) │
│ • state saved automatically after each call │
│ • reactivated on demand, sleeps when idle │
│ • survives restarts (state in store) │
│ │
│ Inside prompt(): │
│ while (true): │
│ stream(llm-proxy) → publish events │
│ if tools: execute via service invocation │
│ if steering queue: drain + loop │
│ if follow-ups: drain + loop │
│ else: break │
└────────────────────────────────────────────────┘
One codebase (apps/agent/), N instances in dapr.yaml, differentiated by environment:
| Env Var | Purpose |
|---|---|
MIND_REPO |
Git repo for SOUL.md + expertise files |
AGENT_TOOLS |
Which tools this agent can use |
TRIGGER |
What kicks off a run (star-poller, pr-watcher, cron, chat) |
MODEL |
Default model preference |
dapr.yaml
─────────
agent:macgyver :3200 MIND_REPO=ianphil/macgyver TRIGGER=star-poller
agent:jack :3201 MIND_REPO=ianphil/jack TRIGGER=pr-watcher
agent:penny :3202 MIND_REPO=ianphil/penny TRIGGER=chat-only
llm-proxy :5100
tool-service :3100
The mind repo is the differentiation. Same runtime, many minds.
Browser (Chat.tsx) Agent Service (Dapr Actor)
│ │
├─ POST actor.prompt("fix the bug") │
├─ Subscribe to pub/sub events ◄────┤─ publish: agent_start
├─ render text_delta ◄────┤─ publish: text_delta
├─ render tool status ◄────┤─ publish: tool_execution_start
├─ POST actor.steer("stop, ...") │
│ ├─ inject steering
├─ render final ◄────┤─ publish: agent_end
│ │
│ ▼
│ ┌──────────────┐ ┌────────────┐
│ │ LLM Proxy │ │ Tool Svc │
│ │ :5100 │ │ :3100 │
│ └──────────────┘ └────────────┘
- No custom HTTP endpoints for prompt/steer/abort — actor methods
- No event SSE endpoint — pub/sub topic
- No state save/load code — actor state management
- No single-run mutex — actors are single-threaded
- No trigger endpoints — input bindings
- The agent loop logic itself (stream → tool calls → loop)
- Tool hook logic (before/after)
- Context transformation pipeline
- Mind loading (already done in
mind-session.mjs) - The thin chat UI event subscriber
mind-session.mjs—loadMindSnapshot(),resolveMindRoot(),bootstrapMind()are already generictool-service— web-fetch and web-search, called via Dapr service invocationllm-proxy— OpenAI-compatible proxy with auto-routing and translation- Dapr state store — conversation persistence
- Dapr Jobs API — scheduling
- Three-tier dev loop (process → Kind → ACA)
- Actor language: Dapr actors have first-class support in .NET, Java, Python, Go. The JS SDK has actor support but it's less mature. Evaluate JS actor SDK vs writing the agent service in .NET/Python.
- Pub/sub for streaming: Pub/sub is message-based, not streaming. For text deltas, options are: batch deltas into small messages, use Dapr's streaming API if available, or keep SSE for the browser and pub/sub for agent-to-agent.
- Long-running actor calls: The agent loop may run for minutes (tool execution, large context). Dapr actors have reentrancy and timeout considerations. May need actor reminders or a workflow pattern for very long runs.
- Dapr Workflows alternative: Dapr Workflows (durable task framework) could model the agent loop as a workflow with activities. Gives retry, compensation, and observability. Trade-off: more ceremony, but better durability guarantees.