Skip to content

feat(runtime,kernel): non-blocking agent_send via the async-task tracker#6044

Merged
houko merged 15 commits into
librefang:mainfrom
DaBlitzStein:feat/agent-send-async
Jun 20, 2026
Merged

feat(runtime,kernel): non-blocking agent_send via the async-task tracker#6044
houko merged 15 commits into
librefang:mainfrom
DaBlitzStein:feat/agent-send-async

Conversation

@DaBlitzStein

Copy link
Copy Markdown
Contributor

Summary

Closes #6043.

agent_send was synchronous-only: tool_agent_send awaited the target agent's full reply as the tool result, so any delegation longer than tool_timeout_secs (default 120s) was killed mid-flight, and the caller's loop stayed blocked the whole time. On production this showed up as a Telegram-facing agent going unresponsive while a research sub-agent ran, then failing with Tool execution timed out after 120s tool=agent_send.

This extends the existing async-task tracker (#4983 — previously wired only to workflow_start) to inter-agent delegation. No new transport: it reuses register_async_task / complete_async_task and the already-present TaskKind::Delegation.

Change

  • New trait method KernelHandle::send_to_agent_async_tracked (crates/librefang-kernel-handle/src/agent_control.rs) with a default impl that falls back to blocking send_to_agent_as for handles without a tracker (mocks/tests).
  • Kernel impl (crates/librefang-kernel/src/kernel/handles/agent_control.rs) mirrors start_workflow_async_tracked: resolves target + caller, registers a TaskKind::Delegation { agent_id, prompt_hash }, spawns the callee loop detached via self_handle, and on completion calls complete_async_task with the reply — which the tracker injects back into the caller's session (mid-turn AgentLoopSignal::TaskCompleted, else wake-idle). Falls back to a blocking send when no caller session is parseable (so the result is never silently dropped).
  • Tool (tool_runner/agent.rs): agent_send gains an "async": boolean param (default false). When true it routes to the tracked path and returns { "task_id", "status": "delegated", "note" } immediately. The depth guard still runs first; the synchronous AGENT_CALL_DEPTH scope is intentionally not carried across the detached spawn. caller_session_id is now threaded from dispatch.rs.
  • Tool definition updated to describe the two modes (blocking vs async).

Blocking agent_send (the default) is byte-for-byte unchanged.

Verification

  • cargo check --workspace --lib — clean.
  • cargo clippy -p librefang-kernel-handle -p librefang-kernel -p librefang-runtime --all-targets -- -D warnings — clean.
  • cargo test -p librefang-runtime --lib tool_runner::tests — 237 passed (incl. 2 new: agent_send_async_routes_to_tracked_path_and_returns_task_id, agent_send_async_without_caller_is_invalid_parameter).
  • cargo test -p librefang-kernel --test async_task_tracker_test — 15 passed (existing TaskKind::Delegation registration / dedup / completion-injection coverage already exercises the delivery side this PR drives).

Notes / follow-ups

  • This is the imperative async primitive. A complementary reactive path (a first-class agent_message_received trigger so a recipient owns its reaction, generalising task_posted) was discussed as a separate, larger design — not in scope here.
  • prompt_hash uses DefaultHasher (opaque, caller's-choice per the field docs); swap to a stable hash if cross-process dedup of delegations is ever needed.

@github-actions github-actions Bot added size/L 250-999 lines changed area/docs Documentation and guides area/runtime Agent loop, LLM drivers, WASM sandbox area/kernel Core kernel (scheduling, RBAC, workflows) labels Jun 9, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGELOG.md: kernel(triggers) entry (#5960) was accidentally clobbered

The diff replaced the first sentence of the existing kernel(triggers): TaskClaimed and TaskCompleted triggers gain an optional creator_match filter bullet (#5960, @nevgenov) with the new feat(runtime,kernel) entry, but the second sentence of the kernel(triggers) entry was left in place and is now dangling as a continuation of the async agent_send description.

This line belongs to #5960, not to agent_send:

Accepts an agent UUID, display name, or "self"; absent (#[serde(default)]) it preserves the legacy fire-for-all behaviour and existing string-form triggers still parse. The original poster is threaded onto the TaskClaimed / TaskCompleted events from the task record.

The CHANGELOG should read (two separate bullets):

- **feat(runtime,kernel): non-blocking `agent_send``"async": true` delegates without blocking the caller** (#6043) (@DaBlitzStein).
  `agent_send` was synchronous-only: it awaited the target agent's full reply as the tool result, so any delegation longer than `tool_timeout_secs` (default 120s) was killed mid-flight and the caller's loop stayed blocked the whole time (a Telegram-facing agent went unresponsive while it waited).
  Passing `"async": true` now registers the delegation on the existing async-task tracker (#4983, previously wired only to `workflow_start`) via the new `KernelHandle::send_to_agent_async_tracked`, spawns the callee loop detached, and returns a `task_id` immediately. The callee's response is injected back into the caller's session when it finishes — mid-turn if the loop is live, else wake-idle — reusing `TaskKind::Delegation` and `complete_async_task`.
  Blocking `agent_send` (the default) is unchanged; the depth guard still rejects too-deep chains before they can fire async.
- **kernel(triggers): `TaskClaimed` and `TaskCompleted` triggers gain an optional `creator_match` filter** (#5960) (@nevgenov), symmetrical to `TaskPosted`'s `assignee_match`, so an orchestrator can scope claim/completion notifications to tasks it originally posted instead of firing on every claim/completion system-wide.
  Accepts an agent UUID, display name, or `"self"`; absent (`#[serde(default)]`) it preserves the legacy fire-for-all behaviour and existing string-form triggers still parse. The original poster is threaded onto the `TaskClaimed` / `TaskCompleted` events from the task record.

Please restore the kernel(triggers) bullet and remove its dangling sentence from the feat(runtime,kernel) entry.


Generated by Claude Code

@github-actions github-actions Bot added the has-conflicts PR has merge conflicts that need resolution label Jun 14, 2026
@DaBlitzStein
DaBlitzStein force-pushed the feat/agent-send-async branch from ffba027 to 22b538a Compare June 15, 2026 14:34
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Jun 15, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGELOG.md: new entry inserted mid-bullet, corrupting the #5960 entry

The #6043 entry is inserted between the first sentence and the continuation sentence of the TaskClaimed/TaskCompleted trigger filter entry (#5960, @nevgenov).

In the resulting file the #5960 entry reads as a single orphaned sentence, and the Accepts an agent UUID, display name, or \"self"`; absent (`#[serde(default)]`) it preserves the legacy fire-for-all behaviour and existing string-form triggers still parse. The original poster is threaded onto the `TaskClaimed` / `TaskCompleted` events from the task record.continuation — which clearly describes thecreator_matchtrigger filter, notagent_send— ends up visually attached to the new#6043` entry.

The fix is to move the #6043 bullet to a position after the full #5960 entry (i.e., after the Accepts an agent UUID… line), so the TaskClaimed entry stays intact:

 - **kernel(triggers): `TaskClaimed` and `TaskCompleted` triggers …** (#5960) (@nevgenov)…
   Accepts an agent UUID, display name, or `"self"`; absent (`#[serde(default)]`) it preserves the legacy fire-for-all behaviour and existing string-form triggers still parse. The original poster is threaded onto the `TaskClaimed` / `TaskCompleted` events from the task record.
+- **feat(runtime,kernel): non-blocking `agent_send` …** (#6043) (@DaBlitzStein).
+

Generated by Claude Code

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One CHANGELOG formatting issue to fix before merge (see inline comment on CHANGELOG.md)."


Generated by Claude Code

Comment thread CHANGELOG.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new feat(runtime,kernel) bullet was inserted in the middle of the existing kernel(triggers): TaskClaimed and TaskCompleted entry, splitting it across two separate bullets.

In the diff, the hunk looks like this:

 - **kernel(triggers): `TaskClaimed` and `TaskCompleted` … system-wide.
+- **feat(runtime,kernel): non-blocking `agent_send` …
+  …
   Accepts an agent UUID, display name, or `"self"`; …

The indented Accepts an agent UUID, display name, or "self"; absent… sentence is the second paragraph of the kernel(triggers) bullet, not a continuation of the new feat(runtime,kernel) entry. As written it now reads as if that paragraph belongs to the async agent_send change, which is incorrect — it describes the creator_match filter for TaskClaimed/TaskCompleted.

Please move the new feat(runtime,kernel) block to after Accepts an agent UUID, display name, or "self"; … so the TaskClaimed/TaskCompleted entry is kept intact.


Generated by Claude Code

@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Jun 16, 2026
@DaBlitzStein
DaBlitzStein force-pushed the feat/agent-send-async branch from 22b538a to 3741eb9 Compare June 16, 2026 09:11
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Jun 16, 2026
@DaBlitzStein
DaBlitzStein force-pushed the feat/agent-send-async branch 2 times, most recently from b4e59db to cfbbc05 Compare June 17, 2026 09:06
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Jun 17, 2026
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Jun 17, 2026
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Jun 17, 2026
@DaBlitzStein
DaBlitzStein force-pushed the feat/agent-send-async branch from d272241 to cde9ae0 Compare June 17, 2026 14:55
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Jun 17, 2026
@DaBlitzStein
DaBlitzStein force-pushed the feat/agent-send-async branch from cde9ae0 to dfbd0aa Compare June 17, 2026 16:51
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Jun 17, 2026
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Jun 17, 2026
@github-actions github-actions Bot added the area/channels Messaging channel adapters label Jun 17, 2026
…ibrefang#6043)

agent_send was synchronous-only: it awaited the target agent's full reply as
the tool result, so any delegation longer than tool_timeout_secs (default
120s) was killed mid-flight and the caller's loop stayed blocked the whole
time (a channel-facing agent went unresponsive while it waited).

Add a non-blocking mode. agent_send with "async": true registers the
delegation on the existing async-task tracker (librefang#4983, previously wired only
to workflow_start) via the new KernelHandle::send_to_agent_async_tracked,
spawns the callee loop detached, and returns a task_id immediately. The
callee's response is injected back into the caller's session on completion
(mid-turn if the loop is live, else wake-idle), reusing TaskKind::Delegation
and complete_async_task.

Blocking agent_send (the default) is byte-for-byte unchanged. The inter-agent
depth guard still rejects too-deep chains before they can fire async; the
synchronous AGENT_CALL_DEPTH scope is intentionally not carried across the
detached spawn.

Tests: agent_send_async_routes_to_tracked_path_and_returns_task_id and
agent_send_async_without_caller_is_invalid_parameter (runtime); the existing
kernel TaskKind::Delegation registration/completion tests already cover the
injection side.
…im preserves them

The ASYNC_RESULT message injected by the async task tracker (librefang#4983)
was created as an unpinned user message. When the wake-idle turn fired,
the immediate session repair / trim pass could consume the freshly
injected message as an orphan or trim candidate, silently dropping the
delegation result. Mark it pinned so the repair and trim paths respect
it — the same contract used for system constraints and critical context.
… + revert unnecessary pinned

When an async agent_send delegation returns a result larger than 500
bytes, the spawn now spills the full response to the artifact store
and includes the real handle in the TaskStatus::Completed payload.
format_task_completion_text (kernel + runtime, kept in sync) surfaces
the handle so the caller can read_artifact the complete content instead
of receiving a 300-char truncated preview that provokes hallucinated
sha256 hashes.

Reverts the pinned:true bandaid on the ASYNC_RESULT injection message
(tool_call.rs) — the session repair / trim does NOT consume the message;
the earlier failures were caused by Viajecinio's session lock, not by
message eviction.
… + forward wake-idle response to home channel

- Add chat_id to PendingTask and register_async_task chain
- In spawn_wake_idle_turn, resolve home channel and forward the
  agent response via send_channel_message when chat_id is available
- Fall back to user_id when platform_id is empty in build_sender_context
  so Telegram adapters that don't populate platform_id still get chat_id
- Add chat_id parameter to send_to_agent_async_tracked (trait + impl)
- Thread chat_id from tool_agent_send through dispatch
@DaBlitzStein
DaBlitzStein force-pushed the feat/agent-send-async branch from b988b62 to 90d5aa9 Compare June 17, 2026 22:37
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Jun 17, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is failing with 9 compile errors — all in crates/librefang-runtime/src/tool_runner/tests/mod.rs. Two root causes:

1. DispatchCapture::send_to_agent_async_tracked is missing the chat_id parameter (E0050)

The trait signature in agent_control.rs was extended to include chat_id: Option<&str> as the last parameter, but the test mock at line 654 only has 5 parameters (excluding &self). Fix:

     async fn send_to_agent_async_tracked(
         &self,
         _agent_id: &str,
         _message: &str,
         _caller_agent_id: &str,
         caller_session_id: Option<&str>,
         _conversation_key: Option<&str>,
+        _chat_id: Option<&str>,
     ) -> Result<String, librefang_kernel_handle::KernelOpError> {

2. Eight test call sites pass 4 arguments to tool_agent_send but the function now takes 5 (E0061)

tool_agent_send gained chat_id: Option<&str> as a fifth parameter, but the test updates only added caller_session_id. Each call needs one more trailing None:

line 856:  tool_agent_send(&input, Some(&kernel), None, None)           → …, None, None, None)
line 872:  tool_agent_send(&input, Some(&kernel), Some("parent-agent"), None)   → …, None, None)
line 893:  same
line 902:  same
line 929:  same
line 964:  tool_agent_send(&input, Some(&kernel), Some("parent-agent"), Some(session)) → …, Some(session), None)
line 1001: tool_agent_send(&input, Some(&kernel), None, None)           → …, None, None, None)
line 1034: tool_agent_send(&input, Some(&kernel), Some("parent-agent"), None)   → …, None, None)

No logic changes required — just the trailing None (tests have no channel context, so None is correct). cargo check -p librefang-runtime will be green after these edits.


Generated by Claude Code

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — the branch does not compile (CI red on Build / Linux aarch64, Test / Unit, Test / Ubuntu shard 1/4, and Quality).

Root cause from the build logs (run 27844448423), and these are internal to this PR — send_to_agent_async_tracked is newly introduced here and the PR is only 1 commit behind main, so it's not a rebase/drift issue:

  • error[E0050]: method 'send_to_agent_async_tracked' has 6 parameters but the declaration in trait 'send_to_agent_async_tracked' has 7 — the trait declaration and its impl disagree on arity; the signature was changed in one place but not the other.
  • Multiple error[E0061]: this function takes 5 arguments but 4 arguments were supplied — call sites weren't updated to the new signature.

Please reconcile the trait/impl signature and update every call site so it compiles, then push — happy to re-review once CI is green. The non-blocking-agent_send direction itself looks right; this is purely a half-applied signature change.

@github-actions github-actions Bot added needs-changes Changes requested by reviewer and removed ready-for-review PR is ready for maintainer review labels Jun 19, 2026
The production trait/impl and tool_agent_send gained a chat_id: Option<&str> parameter, but the mock impl of send_to_agent_async_tracked and the 8 tool_agent_send callsites in tool_runner/tests/mod.rs were not updated, breaking the build (E0050 + E0061).
Adds the missing chat_id param to the mock and None as the chat_id argument at each callsite. Test-only; no production change.
@houko

houko commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Pushed a small test-only fix (f1e1bca) to get the build green.

The production side (trait, real impl, the tool_agent_send callsite) was correct, but two test sites in crates/librefang-runtime/src/tool_runner/tests/mod.rs hadn't been updated for the new chat_id parameter, which is what the red CI was:

  • the mock impl of send_to_agent_async_tracked was missing the 6th param (E0050), and
  • the 8 tool_agent_send(...) callsites passed 4 args instead of 5 (E0061).

Added the missing chat_id param to the mock and None as the chat_id argument at each callsite — no production change. CI should be green now; once it is, this is good to go from my side.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build-breaking test gaps are fixed (f1e1bca): the mock impl and the 8 tool_agent_send callsites now match the new chat_id signature. Approving once CI is green.

@houko
houko enabled auto-merge (squash) June 20, 2026 01:19
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer labels Jun 20, 2026
Evan and others added 5 commits June 20, 2026 10:51
…g#6043)

register_async_task gained a chat_id: Option<String> parameter; the 4 callsites in async_task_tracker_runtime_test.rs passed only 3 args (E0061). Add None (these tests exercise no chat routing). Test-only.
…fang#6043)

The kernel-side async_task_tracker tests had 16 register_async_task callsites passing 3 args; the method now takes a 4th chat_id: Option<String>. Add None (these tests exercise no chat routing). Test-only.
The added None argument pushed 8 inline register_async_task / tool_agent_send calls over rustfmt's width limit; wrap them per rustfmt.
…g#6043)

clippy::needless_borrow — Some(ref cid) on chat_id.as_ref() binds &&String. Use Some(cid) + cid.clone().
@houko
houko merged commit 33c2b13 into librefang:main Jun 20, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) area/runtime Agent loop, LLM drivers, WASM sandbox ready-for-review PR is ready for maintainer review size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(runtime,kernel): non-blocking agent_send — extend the #4983 async-task tracker to inter-agent delegation

2 participants