Current section

Files

Jump to
eai config tools get_subagent_result.exs
Raw

config/tools/get_subagent_result.exs

defmodule Eai.Tool.GetSubagentResult do
@moduledoc """
Step 2 distinguishes three in-flight subagent statuses for an LLM caller:
* `pending` — the subagent was enqueued (the target chat_session was busy
at dispatch time) and is waiting for a free slot. `time` is milliseconds
since `queued_at`.
* `running` — the subagent is actively executing. `time` is milliseconds
since `started_at`.
* `complete` / `error` — terminal. Returned as-is.
Additionally, an entry older than `:eai, :subagent_stale_after_ms` (default
24h) is reported as `status: "error", reason: "stale"` even if its raw
cache status is still `pending` or `running`. This guards against a queue
that was orphaned (e.g. node crash mid-dispatch) leaving an LLM polling
forever.
"""
@behaviour Eai.Tool
@impl true
def schema do
%{
type: "function",
function: %{
name: "get_subagent_result",
description: """
Poll for the result of a sub-agent dispatched via call_subagent.
Returns one of:
- `pending` — subagent is queued (chat_session was busy); will
dispatch automatically when the running task ends.
- `running` — subagent is actively executing.
- `complete` — final answer ready in `answer`.
- `error` — final error in `reason`.
Wait ≥5 s after call_subagent before first poll. Keep calling until
status == "complete" or "error". A `pending` status may last minutes
if the chat_session has a long-running task; do not give up early.
**Token economics:** Every call to this tool is a FULL LLM API roundtrip —
the entire conversation context is re-sent to the model each time. A 50k-token
context polled 60 times = 3 million tokens = real money. Minimize unnecessary polls.
**Cooldown tuning (set_config poll_cooldown_ms):**
- Short sub-agents (quick research): 500 ms — poll fast
- Normal sub-agents (analysis): 2000 ms — default, balanced
- Heavy sub-agents (large codebase): 10000 ms — poll sparingly
- Long sub-agents (multi-step): 30000 ms — heartbeat subscription
Poll history dedup: only the two most recent "pending" / "running" polls are preserved
in the conversation history. Older results are automatically pruned
to keep context lean. Same mechanism as get_task_result.
""",
parameters: %{
type: "object",
properties: %{
subagent_task_id: %{
type: "string",
description: "subagent_task_id returned by call_subagent."
}
},
required: ["subagent_task_id"]
}
}
}
end
@impl true
def execute(args, _pty_session_id, _chat_session_id) do
Process.sleep(Application.get_env(:eai, :poll_cooldown_ms))
case args["subagent_task_id"] do
nil ->
Jason.encode!(%{error: "missing subagent_task_id"})
subagent_task_id ->
result_key = "subagent_result:#{subagent_task_id}"
now = System.monotonic_time(:millisecond)
case Eai.Naming.cache().get(result_key) do
nil ->
Jason.encode!(%{error: "task_not_found"})
# Pending: subagent was enqueued (chat session was busy). Time
# since `queued_at`. Falls through to the stale check below.
%{status: "pending", queued_at: queued_at} = entry ->
elapsed = now - queued_at
if elapsed > stale_after_ms() do
# Mark the entry as error so the LLM doesn't keep polling
# forever. This is a defensive write — it only fires if the
# queue has been orphaned (e.g. node crash before dequeue).
Eai.Naming.cache().put(result_key, %{entry | status: "error", reason: "stale"})
Jason.encode!(%{status: "error", reason: "stale"})
else
Jason.encode!(%{
status: "pending",
time: elapsed,
suggested_poll_ms: Application.get_env(:eai, :poll_cooldown_ms)
})
end
# Running: subagent is actively executing. Time since `started_at`.
# Same stale check as the pending branch.
%{status: "running", started_at: started_at} = entry ->
elapsed = now - started_at
if elapsed > stale_after_ms() do
Eai.Naming.cache().put(result_key, %{entry | status: "error", reason: "stale"})
Jason.encode!(%{status: "error", reason: "stale"})
else
Jason.encode!(%{
status: "running",
time: elapsed,
suggested_poll_ms: Application.get_env(:eai, :poll_cooldown_ms)
})
end
# Complete / error: terminal states, returned verbatim. Do not
# rename any keys here — LLMs may inspect `answer` / `reason`
# directly.
result ->
result |> Eai.Utils.sanitize_value() |> Jason.encode!()
end
end
end
# Default 24h. Override via `config :eai, :subagent_stale_after_ms, ms`.
# If the entry is older, get_subagent_result auto-converts it to
# status: "error", reason: "stale" so an LLM never polls a dead task
# forever.
defp stale_after_ms do
Application.get_env(:eai, :subagent_stale_after_ms, :timer.hours(24))
end
end