Skip to content

Commit 62587c7

Browse files
committed
task tool: parallel fan-out, robust subagent loop, proper card UI
The `task` tool was serialised and fragile — the opposite of its purpose. PARALLELISM (the headline): task carries Effect::Exec for PERMISSION (a subagent can run bash, must gate like bash), and the scheduler read the same effects — Exec means "exclusive", so every task ran alone and blocked all sibling tools. New spec::sched_effects() splits the two questions: task SCHEDULES as {ReadFs, Net} (composable with reads, other tasks, disjoint-path writers) while its permission gate stays Exec. A fan-out of subagents now genuinely runs concurrently — the whole point of the tool. scheduler_path_test +3 cases (fan-out all parallel, task+reads compose, bash still excludes task). ROBUSTNESS (the "fails a lot"): • Provider-agnostic: the runner drove provider::anthropic:: run_stream_sync directly — every task 400'd on OpenAI-compat/ Ollama (wrong wire, wrong auth) and fresh_auth_header would clobber a non-Anthropic key with Anthropic credentials. Config gains the SAME stream seam Deps::stream uses (routes on provider::active() at call time); main.cpp installs it. fresh_auth_header now gated on Kind::Anthropic. Null seam falls back to the old transport. • Transient stream errors (429/529, TLS reset) RETRY with 1/2/4s backoff (3 attempts, reset on success) instead of killing the whole subagent; the partial assistant turn is dropped before retry so the wire pairing stays valid. • Truncated tool-args JSON is SALVAGED via close_partial_json (guarded by ended_inside_string so a half-written body never silently runs), and a bad-args call now counts as a tool round-trip — the model sees the error and re-emits, instead of the loop breaking out and the task dying on the first parse hiccup. • Repeat-failure breaker inside the loop: identical call failed 3× → stop and report, same rule as the parent's doom-loop breaker. • Progress pump throttled to ~12 fps (was per-delta) so a parallel fan-out doesn't flood the UI msg queue. UI: task is now "Agent" with its own identity hue (bright magenta) and "agent" category chip; detail line = "type · description" (agent type leads — it's the discriminator in a fan-out; falls back to the prompt's first line, UTF-8-safe clamp); settled detail keeps just "N turns" (type no longer duplicated). Live activity feed shows ✓/✗ with the tool's target (path/pattern/command) instead of bare names, plus ↻ retry lines; live card body grew to 8 rows (report stays a 5-row glance, full text in transcript + Ctrl+O viewer).
1 parent d91ff7b commit 62587c7

8 files changed

Lines changed: 255 additions & 50 deletions

File tree

include/agentty/tool/spec.hpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,31 @@ inline constexpr std::array kCatalog = {
229229
return nullptr;
230230
}
231231

232+
// ── Scheduling-effects view ──────────────────────────────────────
233+
// `effects` answers the PERMISSION question ("how much trust does this
234+
// tool need?"); this answers the CONCURRENCY question ("what does it
235+
// actually contend on?"). They coincide for every tool except `task`:
236+
//
237+
// • task carries Effect::Exec for permission — a subagent can run bash,
238+
// so it must gate like bash.
239+
// • But for SCHEDULING, Exec means "exclusive: unbounded blast radius,
240+
// nothing else may run" — which serialised every task and blocked all
241+
// sibling tools behind a running subagent. That defeats the tool's
242+
// entire purpose: task exists precisely so the model can fan out
243+
// several isolated investigations CONCURRENTLY (Claude Code runs its
244+
// Task tool in parallel waves for the same reason). The subagent's
245+
// own tool calls are individually effect-gated + path-scheduled
246+
// inside its loop, so the parent scheduling it as coarse-Exec is
247+
// double-counting contention that the inner dispatch already manages.
248+
//
249+
// So task schedules as {ReadFs, Net} — composable with reads, with other
250+
// tasks, and with disjoint-path writers — while its permission gate stays
251+
// Exec. Any future tool with the same split gets a row here.
252+
[[nodiscard]] constexpr EffectSet sched_effects(const ToolSpec& s) noexcept {
253+
if (s.name == "task") return {Effect::ReadFs, Effect::Net};
254+
return s.effects;
255+
}
256+
232257
// Fixed-string non-type template parameter so a tool factory can write
233258
// `spec::require<"bash">()` and have the misspelling caught at compile
234259
// time. The instantiation site evaluates `lookup` in a constant

include/agentty/tool/subagent.hpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
// without a default model), the tool returns a clear "unavailable"
99
// error instead of crashing.
1010

11+
#include <functional>
1112
#include <string>
1213

1314
#include "agentty/auth/auth.hpp"
15+
#include "agentty/provider/provider.hpp"
1416

1517
namespace agentty::tools::subagent {
1618

@@ -19,6 +21,16 @@ struct Config {
1921
auth::AuthHeader auth; // wire credential for the sub-stream
2022
std::string model; // model id for sub-agent turns
2123
bool installed = false;
24+
25+
// Provider-agnostic stream seam — the SAME dispatch main.cpp installs
26+
// into Deps::stream (routes on provider::active() at call time:
27+
// Anthropic / OpenAI-compat / Ollama native). Installed alongside auth
28+
// so a subagent talks to whatever backend the USER selected instead of
29+
// hardcoding the Anthropic transport — previously `task` failed on
30+
// every non-Anthropic provider (wrong wire, wrong auth). Null ⇒ the
31+
// runner falls back to the Anthropic transport (old behaviour, keeps
32+
// tests that install only auth+model working).
33+
std::function<void(provider::Request, provider::EventSink)> stream;
2234
};
2335

2436
// Install the subagent config (call once at startup, after auth resolves).

src/runtime/app/cmd_factory.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,8 +774,13 @@ namespace {
774774
} // namespace
775775

776776
SchedDecision schedule_parallel_batch(const std::vector<ToolUse>& batch) {
777+
// Scheduling reads spec::sched_effects, NOT the raw permission effects:
778+
// `task` gates as Exec (a subagent can run bash) but SCHEDULES as
779+
// {ReadFs, Net} so a fan-out of subagents actually runs concurrently —
780+
// the whole point of the tool. See spec.hpp::sched_effects.
777781
auto effects_of = [](const ToolName& n) -> tools::EffectSet {
778-
if (const auto* sp = tools::spec::lookup(n.value)) return sp->effects;
782+
if (const auto* sp = tools::spec::lookup(n.value))
783+
return tools::spec::sched_effects(*sp);
779784
return tools::EffectSet{{tools::Effect::Exec}};
780785
};
781786
tools::EffectSet active_effects;

src/runtime/main.cpp

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -397,18 +397,21 @@ int main(int argc, char** argv) {
397397
.auth = provider_auth,
398398
});
399399

400-
// ── Wire the subagent (`task` tool) seam ────────────────────────────
400+
// ── Wire the subagent (`task` tool) seam ────────────────────
401401
// Process-global config the `task` tool reads to spin up an isolated
402402
// sub-agent loop. Resolve the model the same way the TUI / ACP paths
403-
// do (-m override → saved setting → built-in default).
403+
// do (-m override → saved setting → built-in default). The stream fn
404+
// is the SAME provider dispatch Deps::stream uses (routes on
405+
// provider::active() at call time), so subagents work on Anthropic,
406+
// OpenAI-compat, and Ollama alike — not just the Anthropic transport.
404407
{
405408
auto sa_settings = persistence::load_settings();
406409
std::string sa_model =
407410
!args.cli_model.empty() ? args.cli_model
408411
: !sa_settings.model_id.empty() ? sa_settings.model_id.value
409412
: std::string{"claude-opus-4-5"};
410413
tools::subagent::install(tools::subagent::Config{
411-
provider_auth, std::move(sa_model), true});
414+
provider_auth, std::move(sa_model), true, stream_fn});
412415
}
413416

414417
// ── MCP server mode: serve agentty's native tools over MCP (stdio) ──

src/runtime/view/thread/turn/agent_timeline/tool_body_preview.cpp

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -568,18 +568,20 @@ maya::ToolBodyPreview::Config tool_body_preview_config(
568568
// BashOutput gives the tail-oriented "watch it work" look while
569569
// running; CodeBlock shows the full report once settled.
570570
if (n == "task") {
571-
// Hard 5-row ceiling on the card body: a subagent report can be
572-
// long, but the timeline card is a glance-able summary — the full
573-
// report is always in the transcript. Both the live feed and the
574-
// settled report are head-capped to kTaskBodyRows.
575-
constexpr std::size_t kTaskBodyRows = 5;
571+
// Card body ceilings: the LIVE feed gets more room (it's the only
572+
// window into what the subagent is doing — ⚙ calls, ✓/✗ results,
573+
// ↻ retries — and it's transient), while the SETTLED report stays a
574+
// tight glance-able summary; the full report is in the transcript
575+
// and the Ctrl+O viewer.
576+
constexpr std::size_t kTaskLiveRows = 8;
577+
constexpr std::size_t kTaskReportRows = 5;
576578
if (tc.is_running()) {
577579
if (!tc.progress_text().empty()) {
578580
out.kind = Kind::BashOutput;
579581
// BashOutput is tail-oriented (newest activity at the
580582
// bottom) — keep the last few feed lines so the running
581583
// card shows the CURRENT step, not the stale header.
582-
out.text = tail_window(tc.progress_text(), kTaskBodyRows);
584+
out.text = tail_window(tc.progress_text(), kTaskLiveRows);
583585
out.text_color = text_tertiary;
584586
out.is_streaming = true;
585587
}
@@ -598,8 +600,8 @@ maya::ToolBodyPreview::Config tool_body_preview_config(
598600
body.remove_prefix(1);
599601
}
600602
out.kind = Kind::CodeBlock;
601-
// keep 4 content lines + 1 "⋯ N more" marker = 5 rows max.
602-
out.text = head_window(body, kTaskBodyRows - 1);
603+
// keep content lines + 1 "⋯ N more" marker = ceiling rows max.
604+
out.text = head_window(body, kTaskReportRows - 1);
603605
out.text_color = text_tertiary;
604606
// We pre-sliced to the ceiling; render it verbatim (no further
605607
// head+tail elision that would fight our marker).

src/runtime/view/thread/turn/agent_timeline/tool_helpers.cpp

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
#include "agentty/runtime/view/palette.hpp"
1111
#include "agentty/runtime/view/thread/turn/agent_timeline/tool_args.hpp"
12+
#include "agentty/tool/util/utf8.hpp"
1213

1314
namespace agentty::ui {
1415

@@ -33,6 +34,7 @@ std::string tool_display_name(const std::string& n) {
3334
if (n == "git_diff") return "Git Diff";
3435
if (n == "git_log") return "Git Log";
3536
if (n == "git_commit") return "Git Commit";
37+
if (n == "task") return "Agent";
3638
return n;
3739
}
3840

@@ -58,6 +60,7 @@ maya::Color tool_category_color(const std::string& n) {
5860
if (n == "edit" || n == "write") return role_brand;
5961
if (n == "bash") return code_text;
6062
if (n == "todo") return status_warn;
63+
if (n == "task") return role_brand_alt; // agent identity
6164
if (n.rfind("git_", 0) == 0) return role_info;
6265
return code_path;
6366
}
@@ -66,6 +69,7 @@ std::string_view tool_category_label(const std::string& n) {
6669
if (n == "edit" || n == "write") return "mutate";
6770
if (n == "bash") return "execute";
6871
if (n == "todo") return "plan";
72+
if (n == "task") return "agent";
6973
if (n.rfind("git_", 0) == 0) return "vcs";
7074
return "inspect";
7175
}
@@ -384,17 +388,37 @@ std::string tool_timeline_detail(const ToolUse& tc) {
384388
return "\xe2\x80\xa6";
385389
}
386390
if (n == "task") {
387-
// Header detail = the model's one-line display_description; once the
388-
// subagent settles, append its turn count parsed from the report
389-
// header ("Subagent report (N turns):") so the card doubles as a
390-
// compact result log without expanding.
391-
std::string detail = safe("display_description");
392-
if (detail.empty()) detail = "subagent";
391+
// Header detail: "type · what it's doing". The agent type leads —
392+
// in a parallel fan-out of subagents it's the discriminator the
393+
// user scans for. Falls back to the prompt's first line when the
394+
// model omitted display_description. Once settled, append the turn
395+
// count parsed from the report header ("Subagent report (type, N
396+
// turns):") so the card doubles as a compact result log.
397+
std::string type = safe("agent_type");
398+
if (type.empty()) type = "general";
399+
std::string what = safe("display_description");
400+
if (what.empty()) {
401+
what = safe("prompt");
402+
if (auto nl = what.find('\n'); nl != std::string::npos)
403+
what.resize(nl);
404+
if (what.size() > 60) {
405+
what.resize(tools::util::safe_utf8_cut(what, 57));
406+
what += "\xe2\x80\xa6";
407+
}
408+
}
409+
std::string detail = type;
410+
if (!what.empty()) detail += " \xc2\xb7 " + what;
393411
if (tc.is_terminal()) {
394412
const auto& out = tc.output();
395413
if (auto p = out.find("report ("); p != std::string::npos) {
396-
if (auto end = out.find(')', p); end != std::string::npos)
397-
detail += " \xc2\xb7 " + out.substr(p + 8, end - (p + 8));
414+
if (auto end = out.find(')', p); end != std::string::npos) {
415+
// "(type, N turns)" — keep only the "N turns" half; the
416+
// type is already leading the detail.
417+
std::string inner = out.substr(p + 8, end - (p + 8));
418+
if (auto comma = inner.find(", "); comma != std::string::npos)
419+
inner = inner.substr(comma + 2);
420+
detail += " \xc2\xb7 " + inner;
421+
}
398422
}
399423
if (tc.is_failed()) detail += " \xc2\xb7 failed";
400424
}

0 commit comments

Comments
 (0)