Skip to content

chore: unify tool call result into tool dispatch result - #34

Merged
bobrykov merged 4 commits into
masterfrom
feat/tool-call-result-unification
May 29, 2026
Merged

chore: unify tool call result into tool dispatch result#34
bobrykov merged 4 commits into
masterfrom
feat/tool-call-result-unification

Conversation

@bobrykov

Copy link
Copy Markdown
Contributor

No description provided.

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@bobrykov, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 36 minutes and 43 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0690a236-9e76-4582-82ed-d74ff6924a67

📥 Commits

Reviewing files that changed from the base of the PR and between e02c7ec and 5b1b690.

📒 Files selected for processing (3)
  • src/engine/bare/dispatch.rs
  • src/engine/bare/stream.rs
  • src/stream/handler.rs
📝 Walkthrough

Walkthrough

Consolidates ToolDispatchResult into core types (replacing ToolCallResult) and rewires middleware and BareLoop: adds a tool dispatch pipeline, recovery, hooks/health recording, emission/observer helpers, message builders, streaming, and context compaction; tests updated accordingly.

Changes

Tool Dispatch Result Consolidation & Engine Flow

Layer / File(s) Summary
Core ToolDispatchResult type and TurnResult
src/core/types.rs, src/core.rs
Introduce ToolDispatchResult (fields: tool_call_id: String, output: ToolContent, is_error: bool, duration: Duration, resolved_tool_name: String), converters/builders, update imports and TurnResult.tool_results to Vec<ToolDispatchResult>, and update module docs.
Middleware re-export & tests
src/engine/middleware.rs
Remove local ToolDispatchResult; pub use crate::core::types::ToolDispatchResult; refactor terminal dispatch to use ToolDispatchResult::from_result(...); add unit tests for conversions and builders.
BareLoop integration and run-loop updates
src/engine/bare.rs
Import ToolDispatchResult, reset managers at session start, record model success and API failures via fallback manager, remove old ToolCallResult re-export, update relevant tests and docs.
Dispatch pipeline & recovery
src/engine/bare/dispatch.rs
Add dispatch_tools, dispatch_tool_with_recovery, dispatch_via_pipeline, pre/post hook checks (check_pre_tool_use_hooks, notify_post_tool_use_hooks), record_tool_health, and recover_tool_error, returning ToolDispatchResult results and supporting retry/skip/fail decisions.
Emission & observer helpers
src/engine/bare/emission.rs
Add notify_* observer callbacks and emit_* EventSink helpers for session/turn/tool events plus millis_u64 duration helper.
Message helpers & result builder
src/engine/bare/message.rs
Add helpers: extract_text, extract_tool_calls, build_tool_result_message(results: Vec<ToolDispatchResult>), build_tool_schemas, and build_tool_context.
Streaming phase and handler mapping
src/engine/bare/stream.rs
Add stream_turn (inline streaming), stream_turn_via_handler, stream consumption via StreamAccumulator, cancellation handling, usage capture, and map_handler_error.
Context compaction
src/engine/bare/compact.rs
Add maybe_compact_context to run (optionally hook-gated) compaction via ContextManager::ensure_context_fits, update conversation, emit compaction events, or return AgentError::ContextExceeded.

Sequence Diagram

sequenceDiagram
  participant Client
  participant BareLoop
  participant ToolPipeline
  participant ToolRegistry
  participant Reflector
  participant Recovery
  participant EventSink

  Client->>BareLoop: start turn / stream request
  BareLoop->>ToolRegistry: lookup tool / pipeline
  BareLoop->>ToolPipeline: invoke pipeline.invoke(ctx) or call tool via select!
  ToolPipeline-->>BareLoop: ToolOutput / ToolError (with duration)
  BareLoop->>Reflector: reflect(error_text) (on failure)
  Reflector-->>Recovery: analysis
  Recovery-->>BareLoop: RecoveryAction (Retry/Skip/Fail)
  BareLoop->>EventSink: emit tool start/complete events (with duration, is_error)
  BareLoop-->>Client: return Message, Usage, StreamStopReason
Loading

Possibly related PRs

  • dch-labs/loopctl#20: Earlier work introducing middleware-side ToolDispatchResult; this PR moves that type into core and updates consumers.
  • dch-labs/loopctl#32: Modifications to BareLoop’s tool-dispatch flow that touch the same dispatch-result plumbing and recovery integration.
  • dch-labs/loopctl#22: Adds ContextManager compaction infrastructure that is invoked by the new maybe_compact_context method.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: unifying ToolCallResult into ToolDispatchResult across the codebase, which is the core objective of this PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 50.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tool-call-result-unification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/engine/middleware.rs (1)

487-514: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Populate tool_call_id on middleware dispatch results.

This function now builds the unified ToolDispatchResult, but it never copies ctx.call_id into it. ToolDispatchContext::call_id is the correlation key for mapping the tool response back to the originating model tool call, so leaving it unset will produce uncorrelated results downstream.

Suggested fix
     fn dispatch(
         &self,
         ctx: &mut ToolDispatchContext,
     ) -> Pin<Box<dyn Future<Output = ToolDispatchResult> + Send + '_>> {
         let tool_name = ctx.tool_name.clone();
+        let call_id = ctx.call_id.clone();
         let input = ctx.input.clone();
         let tool_ctx = ctx.tool_context.clone();
         let registry = Arc::clone(&self.registry);
         let cancel = Arc::clone(&ctx.cancel);

         Box::pin(async move {
             let start = Instant::now();
             let Some(tool) = registry.get(&tool_name) else {
                 let available: Vec<String> = registry.tool_names();
                 let available_refs: Vec<&str> = available.iter().map(String::as_str).collect();
                 let error = AgentError::tool_not_found(&tool_name, &available_refs);
-                return ToolDispatchResult::err(&tool_name, error.to_string(), start.elapsed());
+                let mut result =
+                    ToolDispatchResult::err(&tool_name, error.to_string(), start.elapsed());
+                result.tool_call_id = Some(call_id.clone());
+                return result;
             };

             let call_result = tokio::select! {
                 r = tool.call(input, &tool_ctx) => r,
                 () = cancel.notified() => {
-                    return ToolDispatchResult::err(
+                    let mut result = ToolDispatchResult::err(
                         &tool_name,
                         format!("Tool '{tool_name}' cancelled"),
                         start.elapsed(),
                     );
+                    result.tool_call_id = Some(call_id.clone());
+                    return result;
                 }
             };

             let duration = start.elapsed();
-            ToolDispatchResult::from_result(&tool_name, call_result, duration)
+            let mut result = ToolDispatchResult::from_result(&tool_name, call_result, duration);
+            result.tool_call_id = Some(call_id);
+            result
         })
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/engine/middleware.rs` around lines 487 - 514, The middleware currently
never sets the correlation key ctx.call_id on ToolDispatchResult, so capture
ctx.call_id (e.g. let call_id = ctx.call_id.clone();) and propagate it into the
dispatch result in both error and success paths: when returning
ToolDispatchResult::err(...) set the tool_call_id to call_id (either by passing
it into the constructor if available or by mutating the returned
ToolDispatchResult.tool_call_id = call_id.clone()), and likewise after calling
ToolDispatchResult::from_result(&tool_name, call_result, duration) assign its
tool_call_id = call_id.clone() before returning; update all returns in this
function (the registry-miss error, the cancelled branch, and the normal result
path) to include the call_id so downstream code can correlate responses.
🧹 Nitpick comments (1)
src/engine/bare.rs (1)

1812-1814: ⚡ Quick win

Preserve pipeline-provided tool_call_id instead of overwriting it.

dispatch_via_pipeline currently discards dispatch_result.tool_call_id and always writes Some(tc.id.clone()). Keeping the pipeline value (with fallback) avoids losing middleware-level normalization/rewrite behavior.

Suggested patch
-        Ok(ToolDispatchResult {
-            tool_call_id: Some(tc.id.clone()),
+        Ok(ToolDispatchResult {
+            tool_call_id: dispatch_result
+                .tool_call_id
+                .or_else(|| Some(tc.id.clone())),
             output: dispatch_result.output,
             is_error: dispatch_result.is_error,
             duration: dispatch_result.duration,
             resolved_tool_name: dispatch_result.resolved_tool_name,
         })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/engine/bare.rs` around lines 1812 - 1814, In dispatch_via_pipeline, don't
unconditionally overwrite the pipeline-provided tool_call_id; when constructing
the Ok(ToolDispatchResult { ... }) use dispatch_result.tool_call_id if it is
Some, and only fall back to Some(tc.id.clone()) when
dispatch_result.tool_call_id is None — i.e., set tool_call_id =
dispatch_result.tool_call_id.or(Some(tc.id.clone())) (referencing
dispatch_result, tc, and ToolDispatchResult).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/types.rs`:
- Around line 619-624: ToolDispatchResult currently allows missing tool_call_id
(pub tool_call_id: Option<String>) and public constructors/From<ToolOutput>
create instances with None, which lets uncorrelated results escape; change the
public API so ToolDispatchResult always contains a concrete call id (make
tool_call_id: String) and make existing constructors/From implementations either
require a call id or be made private/internal, or alternatively introduce an
internal PreDispatchResult (with Option<String>) used inside the dispatch layer
and expose only the correlated ToolDispatchResult via with_call_id; update
with_call_id to consume/produce the non-optional ToolDispatchResult and fix all
From<ToolOutput> and constructors to produce the internal pre-type or accept a
call id, and adjust MessagePart::tool_result usage to assume a present id.

---

Outside diff comments:
In `@src/engine/middleware.rs`:
- Around line 487-514: The middleware currently never sets the correlation key
ctx.call_id on ToolDispatchResult, so capture ctx.call_id (e.g. let call_id =
ctx.call_id.clone();) and propagate it into the dispatch result in both error
and success paths: when returning ToolDispatchResult::err(...) set the
tool_call_id to call_id (either by passing it into the constructor if available
or by mutating the returned ToolDispatchResult.tool_call_id = call_id.clone()),
and likewise after calling ToolDispatchResult::from_result(&tool_name,
call_result, duration) assign its tool_call_id = call_id.clone() before
returning; update all returns in this function (the registry-miss error, the
cancelled branch, and the normal result path) to include the call_id so
downstream code can correlate responses.

---

Nitpick comments:
In `@src/engine/bare.rs`:
- Around line 1812-1814: In dispatch_via_pipeline, don't unconditionally
overwrite the pipeline-provided tool_call_id; when constructing the
Ok(ToolDispatchResult { ... }) use dispatch_result.tool_call_id if it is Some,
and only fall back to Some(tc.id.clone()) when dispatch_result.tool_call_id is
None — i.e., set tool_call_id =
dispatch_result.tool_call_id.or(Some(tc.id.clone())) (referencing
dispatch_result, tc, and ToolDispatchResult).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fc27225-60c4-4513-8993-d93783f9fcb6

📥 Commits

Reviewing files that changed from the base of the PR and between 8a2f7fb and fc07ab8.

📒 Files selected for processing (4)
  • src/core.rs
  • src/core/types.rs
  • src/engine/bare.rs
  • src/engine/middleware.rs

Comment thread src/core/types.rs Outdated
Comment on lines +619 to +624
/// The tool call this result is for.
///
/// Matches [`ToolCall::id`] to correlate results back to their
/// originating requests.
pub tool_call_id: String,
/// originating requests. Set by the engine after the middleware
/// pipeline completes via [`with_call_id`](Self::with_call_id).
pub tool_call_id: Option<String>,

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't let public dispatch results escape without a call ID.

ToolDispatchResult now models tool_call_id as optional, and the public constructors/From<ToolOutput> all create instances with None. Downstream, MessagePart::tool_result still requires a concrete call ID (src/message.rs:522-532), and this PR’s engine path falls back to unwrap_or_default() when converting these results back into tool-result messages. A missed with_call_id(...) therefore becomes an empty ID instead of preserving ToolCall::id, which breaks result correlation and can produce invalid tool-result messages. Consider keeping the public type fully correlated, or introducing an internal pre-correlation builder so incomplete results cannot escape the dispatch layer.

Also applies to: 661-684, 776-784

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/types.rs` around lines 619 - 624, ToolDispatchResult currently
allows missing tool_call_id (pub tool_call_id: Option<String>) and public
constructors/From<ToolOutput> create instances with None, which lets
uncorrelated results escape; change the public API so ToolDispatchResult always
contains a concrete call id (make tool_call_id: String) and make existing
constructors/From implementations either require a call id or be made
private/internal, or alternatively introduce an internal PreDispatchResult (with
Option<String>) used inside the dispatch layer and expose only the correlated
ToolDispatchResult via with_call_id; update with_call_id to consume/produce the
non-optional ToolDispatchResult and fix all From<ToolOutput> and constructors to
produce the internal pre-type or accept a call id, and adjust
MessagePart::tool_result usage to assume a present id.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/engine/bare/dispatch.rs (1)

404-410: 💤 Low value

Simplify the rebuild and fix the misleading doc.

Since the pipeline already returns a ToolDispatchResult, this manual field-by-field copy can be replaced with with_call_id. The method doc (Lines 332-335) also reads "converts the ToolDispatchResult back to a ToolDispatchResult", a leftover from the rename.

♻️ Use the builder
-        Ok(ToolDispatchResult {
-            tool_call_id: tc.id.clone(),
-            output: dispatch_result.output,
-            is_error: dispatch_result.is_error,
-            duration: dispatch_result.duration,
-            resolved_tool_name: dispatch_result.resolved_tool_name,
-        })
+        Ok(dispatch_result.with_call_id(tc.id.clone()))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/engine/bare/dispatch.rs` around lines 404 - 410, Replace the manual
field-by-field reconstruction of ToolDispatchResult with the provided helper
method: call dispatch_result.with_call_id(tc.id.clone()) instead of building
Ok(ToolDispatchResult { .. }) and remove the misleading doc phrase about
"converts the `ToolDispatchResult` back to a `ToolDispatchResult` in the
with_call_id/doc comment (update the doc to state it attaches/sets the call id
on an existing ToolDispatchResult). Ensure you reference the existing symbols
dispatch_result, tc.id, ToolDispatchResult, and with_call_id when making the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/engine/bare/dispatch.rs`:
- Around line 287-290: The post-tool-use hook is discarding multipart outputs
because the match in the block that builds output_text returns String::new() for
ToolContent::Multipart; change that to flatten/join the multipart pieces to text
the same way as dispatch_via_pipeline and the direct path (i.e. use the existing
text_content() helper or join the parts) so hooks (audit/policy) receive the
full text; consider extracting a shared ToolContent->String helper (e.g.,
ToolContent::text_content or a free function) and use it here instead of
special-casing Multipart.

In `@src/engine/bare/stream.rs`:
- Line 75: The call drop(accumulator.process(&event)) discards the Result from
StreamAccumulator::process(&StreamEvent) and hides
StreamError::InvalidToolInputJson raised at StreamEvent::PartStop; change the
call to propagate the Result instead (e.g., use accumulator.process(&event)? or
map_err/return Err(...)) so the caller returns an error instead of ignoring it;
also update any callers that previously ignored this Result (e.g., places using
.ok() in the stream handler) to propagate or handle the error consistently.

---

Nitpick comments:
In `@src/engine/bare/dispatch.rs`:
- Around line 404-410: Replace the manual field-by-field reconstruction of
ToolDispatchResult with the provided helper method: call
dispatch_result.with_call_id(tc.id.clone()) instead of building
Ok(ToolDispatchResult { .. }) and remove the misleading doc phrase about
"converts the `ToolDispatchResult` back to a `ToolDispatchResult` in the
with_call_id/doc comment (update the doc to state it attaches/sets the call id
on an existing ToolDispatchResult). Ensure you reference the existing symbols
dispatch_result, tc.id, ToolDispatchResult, and with_call_id when making the
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ba39205-2137-4cb6-b8e0-f716da4a426f

📥 Commits

Reviewing files that changed from the base of the PR and between fc07ab8 and d167fd7.

📒 Files selected for processing (7)
  • src/core/types.rs
  • src/engine/bare.rs
  • src/engine/bare/compact.rs
  • src/engine/bare/dispatch.rs
  • src/engine/bare/emission.rs
  • src/engine/bare/message.rs
  • src/engine/bare/stream.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/engine/bare.rs

Comment thread src/engine/bare/dispatch.rs Outdated
Comment thread src/engine/bare/stream.rs Outdated
@bobrykov
bobrykov merged commit 7752f75 into master May 29, 2026
6 checks passed
@bobrykov
bobrykov deleted the feat/tool-call-result-unification branch July 1, 2026 06:34
@coderabbitai coderabbitai Bot mentioned this pull request Jul 20, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Aug 3, 2026
bobrykov added a commit that referenced this pull request Aug 18, 2026
chore: unify tool call result into tool dispatch result
@coderabbitai coderabbitai Bot mentioned this pull request Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant