Iris Task Delegation and Agent Updates
/ 4 min read
I’ve been doing some pretty significant refactoring of how Iris handles sub-agent tasks, and I think this one is worth talking about. The short version: I ripped sub-agent processing out of Laravel Horizon and replaced it with a dedicated daemon process.
The Problem with Queue Workers
When I first built task delegation, I reached for the obvious solution: a Horizon queue. Sub-agent tasks get dispatched, a worker picks them up, done. Simple, right?
Well… not exactly.
Sub-agent tasks aren’t your typical queue job. They can run for several minutes, they stream LLM responses in real-time, and they broadcast tool calls over WebSockets as they happen. Trying to shoehorn all of that into a queue worker model created some friction. Rate limit handling was clunky. When Anthropic pushes back, you ideally want to just sleep and retry inline rather than releasing the job back to the queue and hoping for the best. And debugging was a pain because these long-running tasks would just disappear into Horizon’s dashboard alongside your normal 200ms jobs.
The Daemon Approach
So what I ended up doing was building iris:agent, a standalone Artisan command that runs as a long-lived process, completely independent of Horizon.
php artisan iris:agentIt polls for pending tasks, processes them one at a time, and handles its own lifecycle. When it’s idle, it backs off with exponential sleep intervals so it’s not hammering the database. When a rate limit hits, it reads the retry-after header and sleeps for exactly that long before picking up where it left off.
The daemon traps SIGINT and SIGTERM for graceful shutdown, finishing whatever task it’s currently working on before stopping. This was pretty easy to hook up with Laravel’s built-in signal handling.
One thing I needed was a clean separation between the daemon’s polling loop and the actual task execution logic. I extracted an AgentTaskRunner service that handles the full lifecycle of a single task: marking it running, executing through the SubAgentManager, creating the conversation result, and broadcasting completion events. The daemon just calls $runner->run($task) and handles what comes back.
Better Tool Call Tracking
The other big piece of this update is how we track what sub-agents actually do. Both streaming and non-streaming paths now build proper tool call records that capture everything: the tool name, arguments, results, and whether it was a custom tool or a provider tool (like web search or web fetch). Provider tools get their own event type on the frontend, so you can watch web searches happening in real-time alongside shell commands.
Real-Time Visibility
On the frontend, the background task indicator got some love too. The bot icon in the header is now always visible. You don’t have to have active tasks running to see it. Click it open and you get your 5 most recent tasks with expandable tool call history.
For running tasks, tool calls stream in live as they happen. For completed tasks, you can expand them to lazy-load the full tool call history from the server. Provider tools show up right alongside custom ones, so you get a complete picture of what the sub-agent did.
This is one of those changes that sounds small but makes a huge difference in practice. Being able to glance at the indicator and see “oh, it’s running a web search right now” instead of just staring at a spinner… it changes how you interact with the system.
Sub-Agents Get Smarter
One more thing: sub-agents now receive the same skills prompts as the primary agent. This means when a sub-agent is working on a task, it has access to the same domain-specific knowledge and instructions. Previously they were working with just the base system prompt, which meant they’d sometimes miss context that the primary agent would have had.
Want to dig into the implementation? Iris is available at iris.prismphp.com.