Skip to content

Feat/adds ai orchestration - #57

Open
Devasy wants to merge 7 commits into
mainfrom
feat/adds-ai-orchestration
Open

Feat/adds ai orchestration#57
Devasy wants to merge 7 commits into
mainfrom
feat/adds-ai-orchestration

Conversation

@Devasy

@Devasy Devasy commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • AI coaching and routine optimization now show richer live responses, including progress updates, active tools, and smoother streaming feedback.
    • Assistant messages display improved formatting and more polished chat bubbles, with animated avatars and clearer loading states.
  • Bug Fixes

    • Improved AI request handling with automatic retries and wait-time updates when services are temporarily unavailable.
    • Added more reliable multi-step AI responses, helping the app continue analysis when extra tool use is needed.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Devasy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f0b17df0-47cc-4ca4-ab20-b275b32a08d9

📥 Commits

Reviewing files that changed from the base of the PR and between d4bba51 and 89a5c79.

📒 Files selected for processing (60)
  • workout-logger/android/app/build.gradle.kts
  • workout-logger/calculate_coverage.dart
  • workout-logger/lib/main.dart
  • workout-logger/lib/screens/ai_coach_screen.dart
  • workout-logger/lib/screens/routine_optimizer_screen.dart
  • workout-logger/lib/services/ai/adapters/coach_tool_service_adapter.dart
  • workout-logger/lib/services/ai/agent_event.dart
  • workout-logger/lib/services/ai/coach_tool_service.dart
  • workout-logger/lib/services/ai/gemini_ai_service.dart
  • workout-logger/lib/services/ai/graphs/coach_graph.dart
  • workout-logger/lib/services/ai/graphs/optimizer_graph.dart
  • workout-logger/lib/services/ai/provider/gemini_provider_adapter.dart
  • workout-logger/lib/services/ai/provider/model_message.dart
  • workout-logger/lib/services/ai/provider/model_runtime.dart
  • workout-logger/lib/services/ai/provider/model_step.dart
  • workout-logger/lib/services/ai/provider/provider_metadata.dart
  • workout-logger/lib/services/ai/runtime/agent_artifact.dart
  • workout-logger/lib/services/ai/runtime/agent_context.dart
  • workout-logger/lib/services/ai/runtime/agent_graph.dart
  • workout-logger/lib/services/ai/runtime/agent_interrupt.dart
  • workout-logger/lib/services/ai/runtime/agent_node.dart
  • workout-logger/lib/services/ai/runtime/agent_policies.dart
  • workout-logger/lib/services/ai/runtime/agent_run_state.dart
  • workout-logger/lib/services/ai/runtime/agent_runtime.dart
  • workout-logger/lib/services/ai/runtime/agent_trace.dart
  • workout-logger/lib/services/ai/runtime/nodes/await_user_input_node.dart
  • workout-logger/lib/services/ai/runtime/nodes/complete_node.dart
  • workout-logger/lib/services/ai/runtime/nodes/error_node.dart
  • workout-logger/lib/services/ai/runtime/nodes/execute_tools_node.dart
  • workout-logger/lib/services/ai/runtime/nodes/ingress_node.dart
  • workout-logger/lib/services/ai/runtime/nodes/model_step_node.dart
  • workout-logger/lib/services/ai/runtime/nodes/planner_node.dart
  • workout-logger/lib/services/ai/runtime/nodes/synthesize_artifacts_node.dart
  • workout-logger/lib/services/ai/tools/agent_tool.dart
  • workout-logger/lib/services/ai/tools/builtins/ask_user_questions_tool.dart
  • workout-logger/lib/services/ai/tools/builtins/routine_tools.dart
  • workout-logger/lib/services/ai/tools/builtins/show_graph_tool.dart
  • workout-logger/lib/services/ai/tools/builtins/workout_data_tools.dart
  • workout-logger/lib/services/ai/tools/tool_executor.dart
  • workout-logger/lib/services/ai/tools/tool_metadata.dart
  • workout-logger/lib/services/ai/tools/tool_registry.dart
  • workout-logger/lib/services/ai/tools/tool_result.dart
  • workout-logger/lib/services/ai/tools/tool_spec.dart
  • workout-logger/lib/services/ai/ui/agent_event_mapper.dart
  • workout-logger/lib/services/managers/readiness_manager.dart
  • workout-logger/lib/viewmodels/ai_coach_view_model.dart
  • workout-logger/lib/viewmodels/routine_optimizer_view_model.dart
  • workout-logger/test/ai_coach_view_model_test.dart
  • workout-logger/test/routine_optimizer_screen_test.dart
  • workout-logger/test/routine_optimizer_view_model_test.dart
  • workout-logger/test/services/ai/adapters/coach_tool_service_adapter_test.dart
  • workout-logger/test/services/ai/coach_tool_service_test.dart
  • workout-logger/test/services/ai/provider/gemini_provider_adapter_test.dart
  • workout-logger/test/services/ai/runtime/agent_runtime_test.dart
  • workout-logger/test/services/ai/runtime/nodes_test.dart
  • workout-logger/test/services/ai/tools/builtins_test.dart
  • workout-logger/test/services/ai/tools/routine_tools_test.dart
  • workout-logger/test/services/ai/tools/tool_registry_test.dart
  • workout-logger/test/services/ai/tools/workout_data_tools_test.dart
  • workout-logger/test/test_utils/fake_model_runtime.dart

Walkthrough

This PR adds agent-event streaming, retry handling, and an AgentOrchestrator around Gemini calls. The coach and routine optimizer view models and screens now consume orchestrator events, surface status and active tools, and use updated loading and bubble UI. Tests were updated for the new flow.

Changes

AI orchestration refactor

Layer / File(s) Summary
Retry policy and tests
workout-logger/lib/services/ai/retry_policy.dart, workout-logger/test/retry_policy_test.dart
RetryPolicy adds retry eligibility, Retry-After parsing, backoff timing, retry status callbacks, and execution tests for success, retry, failure, and exhaustion cases.
Gemini service retry wiring
workout-logger/lib/services/ai/gemini_ai_service.dart
GeminiAiService now accepts RetryPolicy, reports retry status, uses thinkingLevel in request bodies, and routes streaming and generate requests through the policy.
Agent events and orchestrator
workout-logger/lib/services/ai/agent_event.dart, workout-logger/lib/services/ai/agent_orchestrator.dart, workout-logger/test/agent_orchestrator_test.dart
AgentEvent introduces streamed text, status, tool activity, retry, error, and chart events; AgentOrchestrator emits them during multi-round tool-driven streaming, and tests cover the event sequences.
Coach provider and UI
workout-logger/lib/main.dart, workout-logger/lib/viewmodels/ai_coach_view_model.dart, workout-logger/lib/screens/ai_coach_screen.dart, workout-logger/test/ai_coach_view_model_test.dart
The app registers AgentOrchestrator, AiCoachViewModel consumes agent events, and the coach screen updates focus handling, streaming bubble content, suggestion chips, assistant bubbles, and avatar animation.
Routine optimizer provider and UI
workout-logger/lib/viewmodels/routine_optimizer_view_model.dart, workout-logger/lib/screens/routine_optimizer_screen.dart, workout-logger/test/routine_optimizer_screen_test.dart, workout-logger/test/routine_optimizer_view_model_test.dart
RoutineOptimizerViewModel consumes agent events, the optimizer screen surfaces status text, active tools, and updated bubble/avatar styling, and the related tests build the new orchestrator dependency graph.

Possibly related PRs

  • Devasy/Workout-logger#49: Introduces the earlier GeminiAiService/IAiService-based coach view model wiring that this PR replaces with AgentOrchestrator and AgentEvent streaming.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: introducing AI orchestration across the app.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

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.

@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.43878% with 477 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.57%. Comparing base (7834a50) to head (89a5c79).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
workout-logger/lib/screens/ai_coach_screen.dart 0.73% 135 Missing ⚠️
...t-logger/lib/screens/routine_optimizer_screen.dart 45.88% 46 Missing ⚠️
...r/lib/viewmodels/routine_optimizer_view_model.dart 39.13% 42 Missing ⚠️
.../services/ai/provider/gemini_provider_adapter.dart 73.22% 34 Missing ⚠️
...kout-logger/lib/services/ai/gemini_ai_service.dart 3.22% 30 Missing ⚠️
workout-logger/lib/services/ai/agent_event.dart 25.80% 23 Missing ⚠️
workout-logger/lib/services/ai/retry_policy.dart 69.56% 21 Missing ⚠️
...services/ai/tools/builtins/workout_data_tools.dart 71.42% 14 Missing ⚠️
...logger/lib/services/ai/provider/model_message.dart 31.25% 11 Missing ⚠️
...out-logger/lib/viewmodels/ai_coach_view_model.dart 69.44% 11 Missing ⚠️
... and 27 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #57      +/-   ##
==========================================
+ Coverage   38.50%   39.57%   +1.07%     
==========================================
  Files          85      121      +36     
  Lines       14084    15023     +939     
==========================================
+ Hits         5423     5946     +523     
- Misses       8661     9077     +416     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@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: 10

Caution

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

⚠️ Outside diff range comments (1)
workout-logger/lib/services/ai/gemini_ai_service.dart (1)

176-197: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Gate thinkingLevel by model family. workout-logger/lib/services/ai/gemini_ai_service.dart still exposes gemini-2.5-flash and gemini-2.5-flash-lite, but _makeBody() always sends generationConfig.thinkingConfig.thinkingLevel. Gemini 2.5 rejects that field, so those requests fail unless you switch 2.5 to thinkingBudget or omit thinkingConfig for that family.

Proposed fix
-          'thinkingConfig': {'thinkingLevel': thinkingLevel},
+          if (_model.startsWith('gemini-3'))
+            'thinkingConfig': {'thinkingLevel': thinkingLevel},
🤖 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 `@workout-logger/lib/services/ai/gemini_ai_service.dart` around lines 176 -
197, The request body builder in `GeminiAIService._makeBody()` always emits
`generationConfig.thinkingConfig.thinkingLevel`, but that field is only valid
for the Gemini 3.x family. Update the body construction to branch by model
family so `gemini-3` uses `thinkingLevel` while `gemini-2.5-flash` and
`gemini-2.5-flash-lite` either omit `thinkingConfig` or send the older
`thinkingBudget` shape. Use the existing model selection logic in
`GeminiAIService` to decide which config to include.
🤖 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 `@workout-logger/lib/services/ai/agent_orchestrator.dart`:
- Around line 150-183: The round handling in agent_orchestrator.dart is still
entering the “no tools used” retry path after a stream/tool exception is
converted into AgentError, which can trigger unwanted extra model calls. Update
the orchestration flow around the streamFuture try/catch/finally and the
subsequent queryNeedsTools check so a round failure is tracked and exits or
rethrows before the missing-tool retry logic runs. Use the existing AgentError,
streamFuture, and queryNeedsTools flow in the round loop to keep failure
handling separate from tool-missing re-prompting.
- Around line 140-146: The orchestrator currently streams coach replies via
streamCoachReply without surfacing retry-wait status updates, so 429/transient
waits never reach the UI. Thread the AI service’s retry-status callback through
AgentOrchestrator’s stream path, and in the callback map retry-wait statuses to
AgentRetryWait so both view models can react consistently. Update the call site
around streamCoachReply and the surrounding orchestrator logic to forward these
statuses instead of dropping them.

In `@workout-logger/lib/services/ai/gemini_ai_service.dart`:
- Around line 225-235: The retry setup in GeminiAiService’s request flow leaks
sockets because each retry creates a new http.Client inside makeRequest without
closing it. Rework the streaming request path around retryPolicy.execute so the
client is created once outside the callback, reused for the request, and always
closed in a finally block after the stream completes or fails. Make sure the
change is applied in the code path that builds the http.Request and calls
client.send, keeping the retry behavior intact while ensuring cleanup.
- Around line 25-29: The Gemini model picker in kGeminiModels is using an
invalid model ID for the Gemini 3 entry. Update the model code in this list from
the current Gemini 3 value to the documented Gemini 3 model ID, and keep the
display label unchanged so the picker still shows “Gemini 3.0 Flash” while the
underlying value uses the valid API identifier.

In `@workout-logger/lib/services/ai/retry_policy.dart`:
- Around line 194-199: The retry handling in retry_policy.dart is using the
wrong response body when throwing a non-retryable error: after draining
retryResponse.stream, the code still passes the earlier body into
parseErrorMessage. Update the error path in the retry logic to parse the body
from retryResponse itself (the response that actually failed), and keep the
existing drain call only for consumption before reading/parsing the failed
response content.
- Around line 165-174: The retry wait calculation in RetryPolicy currently caps
each Retry-After only by maxWait, so repeated 429s can exceed the intended total
retry budget. Update the logic in the retry loop to track elapsed retry time
across attempts, and clamp each computed wait in relation to the remaining
allowed total time before calling onStatus and Future.delayed. Keep the behavior
centered around parseRetryAfter, backoff, maxWait, and maxRetries so the total
time stays within the documented limit.
- Around line 107-126: The parseRetryAfter helper in retry_policy.dart should
handle RFC Retry-After dates with HttpDate.parse instead of the current date
parsing path, since date-form headers are otherwise treated as invalid. Also
update the retry-after seconds handling in parseRetryAfter to clamp negative
delay-seconds to zero before creating the Duration, while still capping values
at maxWait.

In `@workout-logger/lib/viewmodels/ai_coach_view_model.dart`:
- Around line 139-141: The AgentError branch in AICoachViewModel is updating
buffer and calling notifyListeners(), but _streamingText is left unchanged so
the UI can rebuild with stale streamed content. In the ai_coach_view_model.dart
logic around the AgentError case in the stream handling, update _streamingText
at the same time you append the error to buffer, then notify listeners so the
refreshed error text is reflected immediately.

In `@workout-logger/lib/viewmodels/routine_optimizer_view_model.dart`:
- Around line 169-171: The AgentError branch in
routine_optimizer_view_model.dart buffers the error message but never updates
the live streaming state, so the UI keeps showing stale text. In the switch case
for AgentError inside the routine optimizer view model, update _streamingText
with the new buffer contents before calling _notify(), and keep the existing
persistence path intact so the loading bubble refreshes immediately with the
error text.

In `@workout-logger/test/retry_policy_test.dart`:
- Around line 9-32: The RetryPolicy.parseRetryAfter tests only cover
delay-seconds and miss the HTTP-date and negative-value cases called out in the
review. Update the retry policy test in retry_policy_test.dart by adding
expectations around parseRetryAfter for an RFC-style HTTP-date value and for a
negative Retry-After value, using the RetryPolicy symbol and its parseRetryAfter
method so the parser’s contract is verified for both supported formats and
invalid input.

---

Outside diff comments:
In `@workout-logger/lib/services/ai/gemini_ai_service.dart`:
- Around line 176-197: The request body builder in `GeminiAIService._makeBody()`
always emits `generationConfig.thinkingConfig.thinkingLevel`, but that field is
only valid for the Gemini 3.x family. Update the body construction to branch by
model family so `gemini-3` uses `thinkingLevel` while `gemini-2.5-flash` and
`gemini-2.5-flash-lite` either omit `thinkingConfig` or send the older
`thinkingBudget` shape. Use the existing model selection logic in
`GeminiAIService` to decide which config to include.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: 112d5ac9-da73-4a3f-a51f-8aa2f7c34bbe

📥 Commits

Reviewing files that changed from the base of the PR and between fdba044 and d4bba51.

📒 Files selected for processing (14)
  • workout-logger/lib/main.dart
  • workout-logger/lib/screens/ai_coach_screen.dart
  • workout-logger/lib/screens/routine_optimizer_screen.dart
  • workout-logger/lib/services/ai/agent_event.dart
  • workout-logger/lib/services/ai/agent_orchestrator.dart
  • workout-logger/lib/services/ai/gemini_ai_service.dart
  • workout-logger/lib/services/ai/retry_policy.dart
  • workout-logger/lib/viewmodels/ai_coach_view_model.dart
  • workout-logger/lib/viewmodels/routine_optimizer_view_model.dart
  • workout-logger/test/agent_orchestrator_test.dart
  • workout-logger/test/ai_coach_view_model_test.dart
  • workout-logger/test/retry_policy_test.dart
  • workout-logger/test/routine_optimizer_screen_test.dart
  • workout-logger/test/routine_optimizer_view_model_test.dart

Comment on lines +140 to +146
await for (final chunk in _ai.streamCoachReply(
userMessage: currentUserMessage,
systemPrompt: systemPrompt,
history: currentHistory,
tools: tools,
onToolCall: instrumentedToolCall,
)) {

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Emit AgentRetryWait from retry status callbacks.

Both view models handle AgentRetryWait, but this orchestrator never emits it, so 429/transient retry waits won’t surface in the new UI. Thread the retry-status callback from the AI service into this call and map retry-wait statuses to AgentRetryWait.

🤖 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 `@workout-logger/lib/services/ai/agent_orchestrator.dart` around lines 140 -
146, The orchestrator currently streams coach replies via streamCoachReply
without surfacing retry-wait status updates, so 429/transient waits never reach
the UI. Thread the AI service’s retry-status callback through
AgentOrchestrator’s stream path, and in the callback map retry-wait statuses to
AgentRetryWait so both view models can react consistently. Update the call site
around streamCoachReply and the surrounding orchestrator logic to forward these
statuses instead of dropping them.

Comment on lines +150 to +183
} catch (e) {
safeAdd(AgentError('$e'));
} finally {
if (!isClosed) {
isClosed = true;
await controller.close();
}
}
}();

// Yield events from the controller as they arrive.
yield* controller.stream;

// Ensure the stream future completes.
await streamFuture;

final roundReply = currentRoundTextBuffer.toString().trim();

// Check if we need another round: did the user ask a query that needs
// tools, but the model didn't call any tools?
final queryNeedsTools = _queryRequiresTools(userMessage);
if (queryNeedsTools && toolsUsed.isEmpty && round < maxRounds - 1) {
// Model failed to use tools. Update history and feedback prompt.
currentHistory.add(Content.text(currentUserMessage));
currentHistory.add(Content.model([TextPart(roundReply)]));

currentUserMessage = 'You are answering a query about the user\'s progress or history, '
'but you did not query their actual logged workouts. Please use the relevant tools '
'(e.g. get_exercise_performance, get_workouts_in_range, get_personal_records) '
'to retrieve the user\'s real data before answering.';

yield const AgentStatusUpdate('Analyzing further with database tools…');
yield const AgentTextChunk('\n\n'); // Spacer between attempts
continue;

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Stop re-prompting after stream or tool failures.

The catch converts failures into AgentError, then the loop still evaluates the “no tools used” path. For progress queries, a real stream/tool failure can trigger additional model calls; it also masks caller control flow such as the optimizer abort path. Track the round failure and return/rethrow before the missing-tool retry logic.

🤖 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 `@workout-logger/lib/services/ai/agent_orchestrator.dart` around lines 150 -
183, The round handling in agent_orchestrator.dart is still entering the “no
tools used” retry path after a stream/tool exception is converted into
AgentError, which can trigger unwanted extra model calls. Update the
orchestration flow around the streamFuture try/catch/finally and the subsequent
queryNeedsTools check so a round failure is tracked and exits or rethrows before
the missing-tool retry logic runs. Use the existing AgentError, streamFuture,
and queryNeedsTools flow in the round loop to keep failure handling separate
from tool-missing re-prompting.

Comment on lines 25 to +29
// Ordered list of available Gemini models shown in the picker.
const kGeminiModels = [
('gemini-2.5-flash', 'Gemini 2.5 Flash'),
('gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'),
('gemini-3.0-flash', 'Gemini 3.0 Flash'),

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby references.
git ls-files workout-logger/lib/services/ai
printf '\n--- gemini_ai_service.dart (1-220) ---\n'
cat -n workout-logger/lib/services/ai/gemini_ai_service.dart | sed -n '1,220p'

printf '\n--- search for gemini model strings ---\n'
rg -n "gemini-[0-9]" workout-logger -S

Repository: Devasy/Workout-logger

Length of output: 10753


🌐 Web query:

Gemini 3 Flash Preview model code documentation ai.google.dev gemini-3-flash-preview gemini-3.0-flash

💡 Result:

The Gemini 3 Flash model, identified by the model code gemini-3-flash-preview, is a preview-stage model in the Gemini 3 series released on December 17, 2025 [1][2]. It is designed to provide frontier-class intelligence optimized for speed, cost-efficiency, and agentic workflows [1][2]. Key specifications and documentation details for gemini-3-flash-preview include: Model ID: gemini-3-flash-preview [3][2]. Context Window: 1,048,576 tokens (Input) and 65,536 tokens (Output) [3][2]. Knowledge Cutoff: January 2025 [3][2]. Capabilities: Supports multimodal inputs including text, image, video, audio, and PDF [3][2]. It features advanced visual and spatial reasoning, code execution, function calling, search grounding, and Google Maps grounding [3][4][1]. Thinking Levels: The model supports customizable thinking levels to manage reasoning depth, including MINIMAL, MEDIUM, and HIGH (the default) [5][6]. Consumption: Available via the Gemini API, Google AI Studio, and Vertex AI, with support for Batch API, Flex inference, and Priority inference [3][1][2]. Developers are directed to the Gemini 3 Developer Guide for comprehensive information on features, migration, and best practices [5][4]. Note that as of mid-2026, newer models like Gemini 3.5 Flash and various 3.1 series variants have been released, and users are encouraged to check the latest developer documentation for migration guidance and updated feature sets [5][4][7].

Citations:


Use the documented Gemini 3 model code. gemini-3.0-flash isn’t a valid Gemini API model ID; use gemini-3-flash-preview instead.

Proposed fix
-  ('gemini-3.0-flash',      'Gemini 3.0 Flash'),
+  ('gemini-3-flash-preview', 'Gemini 3 Flash Preview'),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Ordered list of available Gemini models shown in the picker.
const kGeminiModels = [
('gemini-2.5-flash', 'Gemini 2.5 Flash'),
('gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'),
('gemini-3.0-flash', 'Gemini 3.0 Flash'),
// Ordered list of available Gemini models shown in the picker.
const kGeminiModels = [
('gemini-2.5-flash', 'Gemini 2.5 Flash'),
('gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'),
('gemini-3-flash-preview', 'Gemini 3 Flash Preview'),
🤖 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 `@workout-logger/lib/services/ai/gemini_ai_service.dart` around lines 25 - 29,
The Gemini model picker in kGeminiModels is using an invalid model ID for the
Gemini 3 entry. Update the model code in this list from the current Gemini 3
value to the documented Gemini 3 model ID, and keep the display label unchanged
so the picker still shows “Gemini 3.0 Flash” while the underlying value uses the
valid API identifier.

Comment on lines +225 to +235
// Use RetryPolicy to establish the initial connection.
final streamed = await retryPolicy.execute(
makeRequest: () {
final client = http.Client();
final request = http.Request('POST', uri)
..headers['Content-Type'] = 'application/json'
..body = encodedBody;
return client.send(request);
},
onStatus: onRetryStatus,
);

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the cited lines
sed -n '180,290p' workout-logger/lib/services/ai/gemini_ai_service.dart

# Look for any existing client lifecycle handling in this service
rg -n "http\.Client|client\.close\(|RetryPolicy|execute\(" workout-logger/lib/services/ai/gemini_ai_service.dart

Repository: Devasy/Workout-logger

Length of output: 4785


Close the manually created http.Client after the stream finishes.
makeRequest allocates a new client on every retry attempt and never closes it, so retries and long-lived streams can leak sockets. Move the client outside the callback and close it in finally.

🤖 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 `@workout-logger/lib/services/ai/gemini_ai_service.dart` around lines 225 -
235, The retry setup in GeminiAiService’s request flow leaks sockets because
each retry creates a new http.Client inside makeRequest without closing it.
Rework the streaming request path around retryPolicy.execute so the client is
created once outside the callback, reused for the request, and always closed in
a finally block after the stream completes or fails. Make sure the change is
applied in the code path that builds the http.Request and calls client.send,
keeping the retry behavior intact while ensuring cleanup.

Comment on lines +107 to +126
Duration? parseRetryAfter(Map<String, String> headers) {
final value = headers['retry-after'] ?? headers['Retry-After'];
if (value == null || value.isEmpty) return null;

// Try as seconds first (most common for Gemini 429s).
final seconds = int.tryParse(value);
if (seconds != null) {
return Duration(seconds: math.min(seconds, maxWait.inSeconds));
}

// Try as HTTP-date.
try {
final date = _parseHttpDate(value);
final diff = date.difference(DateTime.now());
if (diff.isNegative) return Duration.zero;
return diff > maxWait ? maxWait : diff;
} catch (_) {
return null;
}
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="workout-logger/lib/services/ai/retry_policy.dart"
echo "== outline =="
ast-grep outline "$file" --view expanded || true

echo "== lines 1-260 =="
nl -ba "$file" | sed -n '1,260p'

Repository: Devasy/Workout-logger

Length of output: 294


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== matching files =="
git ls-files | rg '^workout-logger/lib/services/ai/.*retry_policy\.dart$|^.*retry_policy\.dart$' || true

echo "== retry policy files =="
fd -a 'retry_policy.dart' . || true

echo "== parseRetryAfter references =="
rg -n "parseRetryAfter|retry-after|Retry-After|HttpDate|DateTime\.parse|_parseHttpDate" workout-logger 2>/dev/null || true

echo "== file content =="
file="workout-logger/lib/services/ai/retry_policy.dart"
if [ -f "$file" ]; then
  awk 'NR>=1 && NR<=260 { printf "%4d  %s\n", NR, $0 }' "$file"
fi

Repository: Devasy/Workout-logger

Length of output: 13569


Use HttpDate.parse here and clamp negative seconds to zero.

DateTime.parse rejects RFC Retry-After dates, so date-form headers are treated as unparseable. Negative delay-seconds also produce negative Durations and can surface as “retrying in -5s”.

🤖 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 `@workout-logger/lib/services/ai/retry_policy.dart` around lines 107 - 126, The
parseRetryAfter helper in retry_policy.dart should handle RFC Retry-After dates
with HttpDate.parse instead of the current date parsing path, since date-form
headers are otherwise treated as invalid. Also update the retry-after seconds
handling in parseRetryAfter to clamp negative delay-seconds to zero before
creating the Duration, while still capping values at maxWait.

Source: Learnings

Comment on lines +165 to +174
if (attempt < maxRetries) {
// Determine wait duration: Retry-After header > backoff.
final retryAfter = parseRetryAfter(response.headers);
final wait = retryAfter ?? backoff(attempt);
final reason = response.statusCode == 429
? 'Rate limit reached'
: 'Server busy (${response.statusCode})';

onStatus?.call(RetryWaiting(wait, reason, attempt + 1, maxRetries));
await Future<void>.delayed(wait);

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep fast retry waits within maxWait.

Line 168 caps each Retry-After to maxWait, not the remaining total budget. With multiple 429 responses carrying long Retry-After values, total retry time can exceed the documented “maximum total time” by maxRetries × maxWait.

Proposed direction
-        final wait = retryAfter ?? backoff(attempt);
+        final requestedWait = retryAfter ?? backoff(attempt);
+        final remaining = maxWait - stopwatch.elapsed;
+        if (remaining <= Duration.zero) {
+          final error = parseErrorMessage(response.statusCode, body);
+          onStatus?.call(RetryExhausted(error, response.statusCode));
+          throw Exception(
+            'API unavailable after ${stopwatch.elapsed.inSeconds}s of retrying: $error',
+          );
+        }
+        final wait = requestedWait > remaining ? remaining : requestedWait;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (attempt < maxRetries) {
// Determine wait duration: Retry-After header > backoff.
final retryAfter = parseRetryAfter(response.headers);
final wait = retryAfter ?? backoff(attempt);
final reason = response.statusCode == 429
? 'Rate limit reached'
: 'Server busy (${response.statusCode})';
onStatus?.call(RetryWaiting(wait, reason, attempt + 1, maxRetries));
await Future<void>.delayed(wait);
if (attempt < maxRetries) {
// Determine wait duration: Retry-After header > backoff.
final requestedWait = retryAfter ?? backoff(attempt);
final remaining = maxWait - stopwatch.elapsed;
if (remaining <= Duration.zero) {
final error = parseErrorMessage(response.statusCode, body);
onStatus?.call(RetryExhausted(error, response.statusCode));
throw Exception(
'API unavailable after ${stopwatch.elapsed.inSeconds}s of retrying: $error',
);
}
final wait = requestedWait > remaining ? remaining : requestedWait;
final reason = response.statusCode == 429
? 'Rate limit reached'
: 'Server busy (${response.statusCode})';
onStatus?.call(RetryWaiting(wait, reason, attempt + 1, maxRetries));
await Future<void>.delayed(wait);
🤖 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 `@workout-logger/lib/services/ai/retry_policy.dart` around lines 165 - 174, The
retry wait calculation in RetryPolicy currently caps each Retry-After only by
maxWait, so repeated 429s can exceed the intended total retry budget. Update the
logic in the retry loop to track elapsed retry time across attempts, and clamp
each computed wait in relation to the remaining allowed total time before
calling onStatus and Future.delayed. Keep the behavior centered around
parseRetryAfter, backoff, maxWait, and maxRetries so the total time stays within
the documented limit.

Comment on lines +194 to +199
// Drain the failed response body.
await retryResponse.stream.bytesToString();
if (!isRetryableStatus(retryResponse.statusCode)) {
final errBody = body; // already drained above
throw Exception(
parseErrorMessage(retryResponse.statusCode, errBody));

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Parse the body from the response that actually failed.

Line 195 drains retryResponse, but Line 197 reuses the earlier body, so a later non-retryable error reports the wrong Gemini error message.

Proposed fix
-          // Drain the failed response body.
-          await retryResponse.stream.bytesToString();
+          final errBody = await retryResponse.stream.bytesToString();
           if (!isRetryableStatus(retryResponse.statusCode)) {
-            final errBody = body; // already drained above
             throw Exception(
                 parseErrorMessage(retryResponse.statusCode, errBody));
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Drain the failed response body.
await retryResponse.stream.bytesToString();
if (!isRetryableStatus(retryResponse.statusCode)) {
final errBody = body; // already drained above
throw Exception(
parseErrorMessage(retryResponse.statusCode, errBody));
final errBody = await retryResponse.stream.bytesToString();
if (!isRetryableStatus(retryResponse.statusCode)) {
throw Exception(
parseErrorMessage(retryResponse.statusCode, errBody));
🤖 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 `@workout-logger/lib/services/ai/retry_policy.dart` around lines 194 - 199, The
retry handling in retry_policy.dart is using the wrong response body when
throwing a non-retryable error: after draining retryResponse.stream, the code
still passes the earlier body into parseErrorMessage. Update the error path in
the retry logic to parse the body from retryResponse itself (the response that
actually failed), and keep the existing drain call only for consumption before
reading/parsing the failed response content.

Comment on lines +139 to +141
case AgentError(:final message):
buffer.write('\n\n_Error: ${message}_');
notifyListeners();

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refresh streaming text for agent errors.

This writes the error into buffer but not _streamingText, so the rebuild triggered by notifyListeners() still shows stale content.

Proposed fix
           case AgentError(:final message):
             buffer.write('\n\n_Error: ${message}_');
+            _streamingText = buffer.toString();
+            _statusText = '';
             notifyListeners();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case AgentError(:final message):
buffer.write('\n\n_Error: ${message}_');
notifyListeners();
case AgentError(:final message):
buffer.write('\n\n_Error: ${message}_');
_streamingText = buffer.toString();
_statusText = '';
notifyListeners();
🤖 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 `@workout-logger/lib/viewmodels/ai_coach_view_model.dart` around lines 139 -
141, The AgentError branch in AICoachViewModel is updating buffer and calling
notifyListeners(), but _streamingText is left unchanged so the UI can rebuild
with stale streamed content. In the ai_coach_view_model.dart logic around the
AgentError case in the stream handling, update _streamingText at the same time
you append the error to buffer, then notify listeners so the refreshed error
text is reflected immediately.

Comment on lines +169 to +171
case AgentError(:final message):
buffer.write('\n\n_Error: ${message}_');
_notify();

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refresh streaming text for agent errors.

This buffers the error but never updates _streamingText, so the loading bubble rebuilds with stale content until persistence finishes.

Proposed fix
           case AgentError(:final message):
             buffer.write('\n\n_Error: ${message}_');
+            _streamingText = buffer.toString();
+            _statusText = '';
             _notify();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case AgentError(:final message):
buffer.write('\n\n_Error: ${message}_');
_notify();
case AgentError(:final message):
buffer.write('\n\n_Error: ${message}_');
_streamingText = buffer.toString();
_statusText = '';
_notify();
🤖 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 `@workout-logger/lib/viewmodels/routine_optimizer_view_model.dart` around lines
169 - 171, The AgentError branch in routine_optimizer_view_model.dart buffers
the error message but never updates the live streaming state, so the UI keeps
showing stale text. In the switch case for AgentError inside the routine
optimizer view model, update _streamingText with the new buffer contents before
calling _notify(), and keep the existing persistence path intact so the loading
bubble refreshes immediately with the error text.

Comment on lines +9 to +32
test('parseRetryAfter returns correct durations', () {
const policy = RetryPolicy(maxWait: Duration(seconds: 60));

// Seconds parsing
expect(
policy.parseRetryAfter({'retry-after': '12'}),
const Duration(seconds: 12),
);
expect(
policy.parseRetryAfter({'Retry-After': '5'}),
const Duration(seconds: 5),
);

// Capped at maxWait
expect(
policy.parseRetryAfter({'retry-after': '120'}),
const Duration(seconds: 60),
);

// Empty or missing
expect(policy.parseRetryAfter({}), null);
expect(policy.parseRetryAfter({'retry-after': ''}), null);
expect(policy.parseRetryAfter({'retry-after': 'abc'}), null);
});

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.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover HTTP-date and invalid negative Retry-After values.

The parser claims HTTP-date support, but these tests only cover delay-seconds. Add coverage for RFC-style dates and negative seconds so the retry countdown contract stays correct. Dart’s HTTP-date parser supports the expected HTTP formats. (api.dart.dev)

Proposed test additions
+import 'dart:io' show HttpDate;
+
@@
       expect(policy.parseRetryAfter({'retry-after': 'abc'}), null);
+
+      final futureHttpDate = HttpDate.format(
+        DateTime.now().toUtc().add(const Duration(seconds: 10)),
+      );
+      final parsed = policy.parseRetryAfter({'retry-after': futureHttpDate});
+      expect(parsed?.inSeconds, inInclusiveRange(8, 10));
+
+      expect(
+        policy.parseRetryAfter({'retry-after': '-5'}),
+        Duration.zero,
+      );
     });

Based on learnings, Web is not a supported target for this app unless the change explicitly enables it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('parseRetryAfter returns correct durations', () {
const policy = RetryPolicy(maxWait: Duration(seconds: 60));
// Seconds parsing
expect(
policy.parseRetryAfter({'retry-after': '12'}),
const Duration(seconds: 12),
);
expect(
policy.parseRetryAfter({'Retry-After': '5'}),
const Duration(seconds: 5),
);
// Capped at maxWait
expect(
policy.parseRetryAfter({'retry-after': '120'}),
const Duration(seconds: 60),
);
// Empty or missing
expect(policy.parseRetryAfter({}), null);
expect(policy.parseRetryAfter({'retry-after': ''}), null);
expect(policy.parseRetryAfter({'retry-after': 'abc'}), null);
});
import 'dart:io' show HttpDate;
test('parseRetryAfter returns correct durations', () {
const policy = RetryPolicy(maxWait: Duration(seconds: 60));
// Seconds parsing
expect(
policy.parseRetryAfter({'retry-after': '12'}),
const Duration(seconds: 12),
);
expect(
policy.parseRetryAfter({'Retry-After': '5'}),
const Duration(seconds: 5),
);
// Capped at maxWait
expect(
policy.parseRetryAfter({'retry-after': '120'}),
const Duration(seconds: 60),
);
// Empty or missing
expect(policy.parseRetryAfter({}), null);
expect(policy.parseRetryAfter({'retry-after': ''}), null);
expect(policy.parseRetryAfter({'retry-after': 'abc'}), null);
final futureHttpDate = HttpDate.format(
DateTime.now().toUtc().add(const Duration(seconds: 10)),
);
final parsed = policy.parseRetryAfter({'retry-after': futureHttpDate});
expect(parsed?.inSeconds, inInclusiveRange(8, 10));
expect(
policy.parseRetryAfter({'retry-after': '-5'}),
Duration.zero,
);
});
🤖 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 `@workout-logger/test/retry_policy_test.dart` around lines 9 - 32, The
RetryPolicy.parseRetryAfter tests only cover delay-seconds and miss the
HTTP-date and negative-value cases called out in the review. Update the retry
policy test in retry_policy_test.dart by adding expectations around
parseRetryAfter for an RFC-style HTTP-date value and for a negative Retry-After
value, using the RetryPolicy symbol and its parseRetryAfter method so the
parser’s contract is verified for both supported formats and invalid input.

Source: Learnings

@Devasy

Devasy commented Jun 27, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai I need you to review the orchestration layer against the likes of langchain/langraph.

As this will be the base for this apps, agentic AI services, and not just that, It will be reused in other apps as well.

There is a future plan off adding tools like: showGraph tool where ai can pull multiple data from sources and then process it and then show a plot on the screen to user. in the chat itself.

just like genui.

So the implementation needs to be scalable and modular to allow this things

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate relevant files =="
fd -i 'agent_orchestrator.dart|agent_event.dart|retry_policy.dart|gemini_ai_service.dart|ai_coach_view_model.dart|routine_optimizer_view_model.dart|agent_orchestrator_test.dart' .

echo
echo "== AST outlines =="
ast-grep outline workout-logger/lib/services/ai/agent_orchestrator.dart --view expanded || true
echo
ast-grep outline workout-logger/lib/services/ai/agent_event.dart --view expanded || true
echo
ast-grep outline workout-logger/lib/services/ai/retry_policy.dart --view expanded || true
echo
ast-grep outline workout-logger/lib/services/ai/gemini_ai_service.dart --view expanded || true
echo
ast-grep outline workout-logger/lib/viewmodels/ai_coach_view_model.dart --view expanded || true
echo
ast-grep outline workout-logger/lib/viewmodels/routine_optimizer_view_model.dart --view expanded || true
echo
ast-grep outline workout-logger/test/agent_orchestrator_test.dart --view expanded || true

echo
echo "== agent_orchestrator.dart =="
sed -n '1,260p' workout-logger/lib/services/ai/agent_orchestrator.dart

echo
echo "== agent_event.dart =="
sed -n '1,220p' workout-logger/lib/services/ai/agent_event.dart

echo
echo "== retry_policy.dart =="
sed -n '1,280p' workout-logger/lib/services/ai/retry_policy.dart

echo
echo "== gemini_ai_service.dart (targeted excerpts) =="
sed -n '1,320p' workout-logger/lib/services/ai/gemini_ai_service.dart

echo
echo "== ai_coach_view_model.dart =="
sed -n '1,260p' workout-logger/lib/viewmodels/ai_coach_view_model.dart

echo
echo "== routine_optimizer_view_model.dart =="
sed -n '1,260p' workout-logger/lib/viewmodels/routine_optimizer_view_model.dart

echo
echo "== agent_orchestrator_test.dart =="
sed -n '1,260p' workout-logger/test/agent_orchestrator_test.dart

Length of output: 51743



@Devasy I reviewed the orchestration layer with a LangChain/LangGraph lens.

Short version: good first orchestration pass for a single app, but not yet the right long-term foundation if this is meant to become a reusable agent runtime across apps.

What is good already

  • AgentOrchestrator + AgentEvent is a strong direction.
    You’ve already moved from “UI talks directly to model” toward an evented runtime.
  • The UI is mostly consuming events rather than provider-specific details, which is the right abstraction boundary.
  • AgentChartData shows you’re already thinking beyond plain text.

Where it falls short for a reusable agent framework

1. The orchestrator is still policy-hardcoded, not graph/state-driven

In workout-logger/lib/services/ai/agent_orchestrator.dart, the core behavior is decided by:

  • _queryRequiresTools(...)
  • _toolLabel(...)
  • a hardcoded re-prompt string
  • a fixed round loop

That works for one coach flow, but it does not scale like LangGraph where the runtime is driven by explicit state + node transitions.

Right now, if you later add:

  • showGraph
  • planner/executor steps
  • human approval steps
  • retrieval/analysis/render subflows
  • app-specific tool sets

you’ll keep growing conditionals inside one orchestrator.

My review take: this should evolve into a small runtime with:

  • AgentState
  • AgentNode
  • AgentTransition
  • ToolExecutionResult
  • Artifact / UiPayload

So the orchestration policy is configurable, not embedded in one class.


2. Tool semantics are mixed with UI presentation

_toolLabel(FunctionCall) lives inside the orchestrator and returns user-facing strings.

That means the orchestration layer currently knows:

  • tool names
  • tool argument shapes
  • how the UI should describe those tools

That is a coupling problem.

For a reusable base, tools should declare their own metadata, e.g.:

  • id
  • display name
  • category
  • output kind (text, table, chartSpec, questionnaire, etc.)
  • optional progress/status formatter

The orchestrator should emit structured events, and the UI should decide how to render them.


3. The orchestrator does not fully own the agent loop

This is the biggest architectural gap compared with LangGraph-style systems.

Your comment in AgentOrchestrator says the actual tool-call loop is already handled inside IAiService.streamCoachReply(...), and the orchestrator wraps it from the outside.

That means the orchestrator is not truly orchestrating the core cycle:

  • model step
  • inspect tool calls
  • execute tools
  • merge results into state
  • decide next node
  • render artifact / ask user / continue

Instead, it is mostly observing a provider-owned loop.

That makes it much harder to support future flows like:

  • multiple tool phases
  • branch-specific retries
  • planner -> tool executor -> synthesizer
  • graph rendering tool chains
  • human-in-the-loop checkpoints

Recommendation: move toward a lower-level AI provider contract, something like:

  • streamModelStep(...)
  • returns text parts, tool call intents, finish reason, provider metadata

Then let the orchestrator own the loop.


4. AgentChartData exists, but the tool/result contract is still too generic

Future showGraph support will need more than Map<String, Object?> tool returns.

Right now tool results are opaque maps, and AgentChartData is not actually part of a typed artifact pipeline.

If you want GenUI-like extensibility, add a typed artifact layer now, e.g.:

  • AgentArtifact.text
  • AgentArtifact.chart
  • AgentArtifact.table
  • AgentArtifact.form
  • AgentArtifact.metricCards

Then tools can return structured artifacts, and the orchestrator can emit them without every screen knowing tool internals.

That will matter a lot when one answer combines:

  • narrative text
  • computed stats
  • chart data
  • follow-up questions

5. Retry plumbing looks incomplete at the orchestration level

workout-logger/lib/services/ai/agent_event.dart defines AgentRetryWait, but in the inspected AgentOrchestrator code I do not see retry status events being bridged from GeminiAiService.onRetryStatus.

So today the event model suggests the UI can show retry countdowns, but the orchestrator does not appear to actually emit them.

That’s an architectural smell: the public event vocabulary is ahead of the real runtime wiring.


6. There is also a reliability bug in the retry layer

In workout-logger/lib/services/ai/retry_policy.dart, during slow-poll retries, if a later retry returns a non-retryable response, the code throws using the old drained body instead of the latest response body.

That can surface the wrong error message and make debugging provider issues harder.

Not orchestration logic per se, but if this is the foundation layer, it matters.


My overall assessment

If I compare this to LangChain/LangGraph style design maturity:

  • Current state: evented chat controller with light multi-round heuristics
  • Needed for reusable base: state machine / graph runtime with typed artifacts and provider-independent execution steps

So I would say:

Good enough for this PR as an app-level orchestration MVP.
Not yet strong enough to be the long-term shared orchestration foundation without another architectural pass.

What I would change before treating this as the shared base

  1. Split provider runtime from orchestration runtime

    • GeminiAiService: model transport only
    • AgentOrchestrator: loop/state/transition ownership
    • ToolExecutor / ToolRegistry: tool dispatch only
  2. Introduce typed run state

    • messages
    • pending tool calls
    • tool outputs
    • artifacts
    • retry state
    • human input state
    • final answer state
  3. Replace heuristic branching with pluggable policies

    • “should use tools?”
    • “should continue?”
    • “should ask user?”
    • “should emit chart?”
    • “should summarize or synthesize?”
  4. Add typed artifact outputs

    • especially for future showGraph
  5. Move tool metadata out of the orchestrator

    • labels, capabilities, render hints, categories
  6. Add traceability

    • per-run execution trace / node trace / tool trace
    • this becomes very important once you reuse this across apps

Review verdict

I like the direction, but I would not freeze this shape as the cross-app base layer yet.
I’d treat this PR as Phase 1: app-level orchestration MVP, then do a follow-up refactor into a proper runtime before multiple apps depend on it.

If you want, I can next turn this into a concrete target architecture for this repo — e.g. a LangGraph-inspired Dart design with:

  • core interfaces
  • node types
  • state model
  • artifact model
  • showGraph flow
  • migration steps from the current code

✏️ Learnings added
Learnt from: Devasy
URL: https://github.com/Devasy/Workout-logger/pull/57

Timestamp: 2026-06-27T06:36:12.976Z
Learning: In the `workout-logger` repository, the AI orchestration layer under `workout-logger/lib/services/ai/` is intended to become a reusable foundation across multiple apps, so future reviews should evaluate it for scalability and modularity, including support for future inline visualization tools such as a `showGraph`/GenUI-style chart-rendering capability within chat.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Devasy

Devasy commented Jun 27, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Can you turn this into a concrete target architecture for this repo like a LangGraph-inspired Dart design

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate likely relevant files =="
fd -i 'iai_service.dart|ai_service.dart|coach_tool_service.dart|conversation_manager.dart|conversation.dart|message.dart|ai_coach_view_model.dart|routine_optimizer_view_model.dart|agent_orchestrator.dart|agent_event.dart|gemini_ai_service.dart' workout-logger/lib

echo
echo "== AST outlines for core AI files =="
for f in \
  workout-logger/lib/services/ai/agent_orchestrator.dart \
  workout-logger/lib/services/ai/agent_event.dart \
  workout-logger/lib/services/ai/gemini_ai_service.dart \
  workout-logger/lib/viewmodels/ai_coach_view_model.dart \
  workout-logger/lib/viewmodels/routine_optimizer_view_model.dart
do
  if [ -f "$f" ]; then
    echo "--- OUTLINE: $f ---"
    ast-grep outline "$f" --view expanded || true
    echo
  fi
done

echo "== Find interface/service definitions =="
rg -n -C3 --glob 'workout-logger/lib/**/*.dart' \
  'abstract class IAiService|class CoachToolService|class ConversationManager|typedef|enum MessageRole|class Conversation|class ChatMessage|class Message' \
  workout-logger/lib

echo
echo "== Read targeted files if present =="
for f in \
  workout-logger/lib/services/ai/ai_service.dart \
  workout-logger/lib/services/ai/i_ai_service.dart \
  workout-logger/lib/services/coach_tool_service.dart \
  workout-logger/lib/services/conversation_manager.dart \
  workout-logger/lib/models/conversation.dart \
  workout-logger/lib/models/chat_message.dart \
  workout-logger/lib/models/message.dart \
  workout-logger/lib/viewmodels/ai_coach_view_model.dart \
  workout-logger/lib/viewmodels/routine_optimizer_view_model.dart \
  workout-logger/lib/services/ai/agent_orchestrator.dart \
  workout-logger/lib/services/ai/agent_event.dart
do
  if [ -f "$f" ]; then
    echo "--- FILE: $f (first 260 lines) ---"
    sed -n '1,260p' "$f"
    echo
  fi
done

Length of output: 32201


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Read AI interface, tool service, and chat models =="
for f in \
  workout-logger/lib/services/interfaces/ai_service_interface.dart \
  workout-logger/lib/services/ai/coach_tool_service.dart \
  workout-logger/lib/services/managers/conversation_manager.dart \
  workout-logger/lib/models/models.dart
do
  echo "--- FILE: $f ---"
  sed -n '1,280p' "$f"
  echo
done

echo "== Focused searches for tool declarations and message persistence APIs =="
rg -n -C3 \
  'buildTools\s*\(|handleCall\s*\(|askUserQuestionsDeclaration|appendMessage\s*\(|startNewConversation\s*\(|selectConversation\s*\(|class ChatMessage|class Conversation' \
  workout-logger/lib/services/ai/coach_tool_service.dart \
  workout-logger/lib/services/managers/conversation_manager.dart \
  workout-logger/lib/models/models.dart \
  workout-logger/lib/services/interfaces/ai_service_interface.dart

Length of output: 33964



@Devasy Yes — here’s a concrete target architecture for this repo that keeps the good parts of the current PR, but evolves it into a LangGraph-inspired Dart runtime that can scale to coach, optimizer, and future GenUI-style tools like showGraph.

Target outcome

Use this mental model:

  • GeminiAiService becomes a provider adapter
  • A new runtime owns the agent loop
  • Tools become registered capabilities with metadata
  • Flows are explicit graphs/state machines
  • UI consumes typed events + artifacts
  • ConversationManager continues to persist chat transcripts

So the system becomes:

flowchart LR
    UI["Screens / ViewModels"] --> RT["AgentRuntime"]
    RT --> G["AgentGraph / Nodes"]
    G --> MR["ModelRuntime"]
    G --> TR["ToolRegistry / ToolExecutor"]
    G --> HR["HumanInterruptHandler"]
    MR --> GP["GeminiProviderAdapter"]
    TR --> TS["CoachToolService-backed tools"]
    RT --> EV["AgentEvent stream"]
    RT --> AF["Typed Artifacts"]
    UI --> CM["ConversationManager"]
Loading

1. What to keep vs what to change

Keep

These are already good foundations:

  • workout-logger/lib/services/ai/agent_event.dart
  • workout-logger/lib/viewmodels/ai_coach_view_model.dart
  • workout-logger/lib/viewmodels/routine_optimizer_view_model.dart
  • workout-logger/lib/services/managers/conversation_manager.dart

Change

These should evolve:

  • workout-logger/lib/services/interfaces/ai_service_interface.dart
    • stop making the provider own the whole tool loop
  • workout-logger/lib/services/ai/agent_orchestrator.dart
    • replace heuristic orchestration with graph-driven runtime
  • workout-logger/lib/services/ai/coach_tool_service.dart
    • split into tool definitions + tool execution

2. Proposed module layout

I’d aim for this structure:

workout-logger/lib/services/ai/
  runtime/
    agent_runtime.dart
    agent_run_state.dart
    agent_graph.dart
    agent_node.dart
    agent_transition.dart
    agent_context.dart
    agent_result.dart
    agent_artifact.dart
    agent_interrupt.dart
    agent_trace.dart
    agent_policies.dart

  provider/
    model_runtime.dart
    model_step.dart
    model_message.dart
    provider_metadata.dart
    gemini_provider_adapter.dart

  tools/
    agent_tool.dart
    tool_registry.dart
    tool_executor.dart
    tool_result.dart
    tool_metadata.dart
    builtins/
      workout_data_tools.dart
      routine_tools.dart
      ask_user_questions_tool.dart
      show_graph_tool.dart

  graphs/
    coach_graph.dart
    optimizer_graph.dart

  adapters/
    coach_tool_service_adapter.dart
    conversation_adapter.dart

  ui/
    agent_event_mapper.dart

You do not need to create all of this in one PR. This is the target shape.


3. Core design: graph + state + node execution

The main shift is:

Current

AgentOrchestrator wraps IAiService.streamCoachReply(...)

Target

AgentRuntime.run(graph, input) executes:

  1. current node
  2. update state
  3. emit events/artifacts
  4. choose next node
  5. continue until done / interrupt / error

That’s the LangGraph-style part.

Core runtime types

sealed class AgentNodeResult {
  const AgentNodeResult();
}

class NextNode extends AgentNodeResult {
  final String nodeId;
  const NextNode(this.nodeId);
}

class CompleteRun extends AgentNodeResult {
  const CompleteRun();
}

class InterruptRun extends AgentNodeResult {
  final AgentInterrupt interrupt;
  const InterruptRun(this.interrupt);
}
class AgentRunState {
  final String runId;
  final String graphId;
  final String userMessage;
  final List<ModelMessage> transcript;
  final List<ToolInvocation> toolCalls;
  final List<AgentArtifact> artifacts;
  final String? statusText;
  final Set<String> activeToolIds;
  final Map<String, Object?> workingMemory;
  final AgentPhase phase;
  final int round;
  final bool isComplete;

  const AgentRunState({
    required this.runId,
    required this.graphId,
    required this.userMessage,
    required this.transcript,
    required this.toolCalls,
    required this.artifacts,
    required this.workingMemory,
    required this.activeToolIds,
    this.statusText,
    this.phase = AgentPhase.idle,
    this.round = 0,
    this.isComplete = false,
  });

  AgentRunState copyWith({...});
}
abstract class AgentNode {
  String get id;
  Future<AgentNodeResult> execute(
    AgentContext ctx,
    AgentRunState state,
  );
}

4. Split the provider contract from orchestration

This is the most important refactor.

Right now IAiService.streamCoachReply(...) hides:

  • model streaming
  • tool call detection
  • tool execution loop
  • result reinjection

That makes the provider too smart.

Replace it with a lower-level model contract

Target provider contract

abstract class ModelRuntime {
  bool get isConfigured;
  String get currentModel;

  Stream<ModelStep> streamStep({
    required String systemPrompt,
    required List<ModelMessage> messages,
    required List<ToolSpec> tools,
  });
}

ModelStep

A single model pass should yield structured outputs:

sealed class ModelStep {
  const ModelStep();
}

class ModelTextDelta extends ModelStep {
  final String text;
  const ModelTextDelta(this.text);
}

class ModelToolCall extends ModelStep {
  final String callId;
  final String toolName;
  final Map<String, Object?> args;
  const ModelToolCall({
    required this.callId,
    required this.toolName,
    required this.args,
  });
}

class ModelFinish extends ModelStep {
  final String reason; // stop, tool_calls, max_tokens, safety, etc.
  const ModelFinish(this.reason);
}

Repo mapping

  • GeminiAiService should become GeminiProviderAdapter
  • it should translate Gemini SDK output into ModelStep
  • it should not execute tools itself

That lets your orchestrator own:

  • multi-step execution
  • retry policy
  • interruptions
  • tool branches
  • artifacts
  • future graph branching

5. Introduce typed tools, not just function declarations

Right now CoachToolService does both:

  • schema declaration
  • execution dispatch
  • tool-label semantics

That should be split.

Target tool contract

abstract class AgentTool {
  String get id;
  ToolMetadata get metadata;
  ToolSpec get spec;

  Future<ToolResult> execute(ToolExecutionContext ctx);
}
class ToolMetadata {
  final String displayName;
  final ToolKind kind; // query, mutation, ui, interrupt, analytics
  final bool readOnly;
  final String? progressLabel;
  final AgentArtifactKind? outputKind;

  const ToolMetadata({
    required this.displayName,
    required this.kind,
    required this.readOnly,
    this.progressLabel,
    this.outputKind,
  });
}
class ToolResult {
  final Map<String, Object?> data;
  final List<AgentArtifact> artifacts;
  final List<AgentEvent> events;
  const ToolResult({
    this.data = const {},
    this.artifacts = const [],
    this.events = const [],
  });
}

Repo mapping

Refactor CoachToolService into:

  • data/query service
  • tool registry adapter

Example split:

  • CoachToolService
    • keep raw data access methods
  • WorkoutDataTool, RoutineTool, AskUserQuestionsTool
    • expose metadata/spec/execute

This removes tool knowledge from the orchestrator.


6. Add typed artifacts now for future GenUI

Your AgentChartData is the right idea, but it should be part of a broader artifact system.

Target artifact model

sealed class AgentArtifact {
  const AgentArtifact();
}

class TextArtifact extends AgentArtifact {
  final String markdown;
  const TextArtifact(this.markdown);
}

class ChartArtifact extends AgentArtifact {
  final String chartType;
  final String title;
  final Map<String, Object?> spec;
  const ChartArtifact({
    required this.chartType,
    required this.title,
    required this.spec,
  });
}

class TableArtifact extends AgentArtifact {
  final String title;
  final List<String> columns;
  final List<List<Object?>> rows;
  const TableArtifact({
    required this.title,
    required this.columns,
    required this.rows,
  });
}

class QuestionFormArtifact extends AgentArtifact {
  final PendingQuestions questions;
  const QuestionFormArtifact(this.questions);
}

Why this matters

A future answer can contain:

  • streamed explanation text
  • routine comparison table
  • progress chart
  • follow-up questions

without overloading a single chat bubble string.


7. Model human-in-the-loop as an interrupt, not a special case

Right now RoutineOptimizerViewModel has special handling for ask_user_questions.

That behavior is valuable, but it belongs in the runtime model.

Target interrupt system

sealed class AgentInterrupt {
  const AgentInterrupt();
}

class AwaitUserQuestions extends AgentInterrupt {
  final PendingQuestions payload;
  const AwaitUserQuestions(this.payload);
}

Then a node can return:

return InterruptRun(
  AwaitUserQuestions(pendingQuestions),
);

And later the runtime resumes with:

runtime.resume(
  runId: runId,
  userInput: {
    'answers': [...]
  },
);

Repo mapping

This turns the optimizer flow into a first-class graph behavior instead of VM-only custom logic.


8. Recommended graph shapes for this repo

You do not need one universal graph first. Start with two graphs.

A. Coach graph

flowchart TD
    A["ingress"] --> B["plan"]
    B --> C["model_step"]
    C -->|tool call| D["execute_tools"]
    D --> E["append_tool_results"]
    E --> C
    C -->|final answer| F["synthesize_artifacts"]
    F --> G["complete"]
Loading

B. Optimizer graph

flowchart TD
    A["ingress"] --> B["plan"]
    B --> C["model_step"]
    C -->|ask_user_questions| D["interrupt_user"]
    D --> E["resume_with_answers"]
    E --> C
    C -->|data tools| F["execute_tools"]
    F --> C
    C -->|final answer| G["synthesize_artifacts"]
    G --> H["complete"]
Loading

9. Concrete node set to implement first

I’d define these node types:

Generic nodes

  • IngressNode
  • PlannerNode
  • ModelStepNode
  • ExecuteToolsNode
  • SynthesizeArtifactsNode
  • CompleteNode
  • ErrorNode

Special nodes

  • AwaitUserInputNode
  • RetryWaitNode
  • ShowGraphNode later

Suggested responsibilities

PlannerNode

  • decides which tools are allowed for this run
  • sets system prompt/profile
  • seeds working memory

ModelStepNode

  • calls ModelRuntime.streamStep(...)
  • emits AgentTextChunk
  • captures tool call intents
  • decides whether to continue or finish

ExecuteToolsNode

  • routes tool calls through ToolRegistry
  • emits AgentToolActivity
  • stores tool outputs in state

SynthesizeArtifactsNode

  • converts raw tool outputs into AgentArtifact
  • emits AgentChartData / future table/form artifacts

10. Event model: keep AgentEvent, but make it runtime-native

You already have the right UI concept. I’d expand it slightly.

Keep

  • AgentTextChunk
  • AgentStatusUpdate
  • AgentToolActivity
  • AgentRetryWait
  • AgentError
  • AgentChartData

Add

class AgentArtifactReady extends AgentEvent {
  final AgentArtifact artifact;
  const AgentArtifactReady(this.artifact);
}

class AgentInterrupted extends AgentEvent {
  final AgentInterrupt interrupt;
  const AgentInterrupted(this.interrupt);
}

class AgentTraceEvent extends AgentEvent {
  final String nodeId;
  final String message;
  const AgentTraceEvent(this.nodeId, this.message);
}

That gives you:

  • UI events
  • debugging/tracing
  • future reusable telemetry

11. Tracing: make runs observable from day 1

Because you want this reused across apps, tracing matters a lot.

Add a run trace model

class AgentTrace {
  final String runId;
  final List<TraceEntry> entries;
  const AgentTrace(this.runId, this.entries);
}

class TraceEntry {
  final DateTime at;
  final String nodeId;
  final String type;
  final Map<String, Object?> data;
  const TraceEntry({
    required this.at,
    required this.nodeId,
    required this.type,
    required this.data,
  });
}

Use it for:

  • node execution
  • tool invocations
  • retries
  • interrupts
  • final outputs

This is the equivalent of “why did the graph do this?”


12. How this maps onto existing repo files

Existing file → target role

workout-logger/lib/services/interfaces/ai_service_interface.dart

Current: backend + orchestration loop
Target: split into:

  • ModelRuntime
  • optional higher-level generation interfaces for non-agent features

workout-logger/lib/services/ai/gemini_ai_service.dart

Current: full provider with retries and looping
Target: GeminiProviderAdapter

  • transport
  • streaming decode
  • model-step translation
  • provider metadata
  • retry hook forwarding

workout-logger/lib/services/ai/agent_orchestrator.dart

Current: heuristic wrapper
Target: replace with:

  • AgentRuntime
  • AgentGraph
  • node implementations

workout-logger/lib/services/ai/coach_tool_service.dart

Current: declarations + execution
Target: split into:

  • CoachToolService as data/query backend
  • AgentTool implementations
  • ToolRegistry

workout-logger/lib/viewmodels/ai_coach_view_model.dart

Current: event consumer
Target: largely stays the same, but consumes:

  • runtime.run(graph: coachGraph, ...)

workout-logger/lib/viewmodels/routine_optimizer_view_model.dart

Current: special-case interrupt handling
Target: use runtime interruption/resume API

workout-logger/lib/services/managers/conversation_manager.dart

Current: transcript persistence
Target: keep it

  • persist human/model transcript
  • artifacts can be stored later if desired, but not required for phase 1

13. Concrete Dart interfaces I’d introduce first

agent_runtime.dart

abstract class AgentRuntime {
  bool get isConfigured;

  Stream<AgentEvent> run({
    required AgentGraph graph,
    required AgentRunInput input,
  });

  Future<void> resume({
    required String runId,
    required Map<String, Object?> payload,
  });
}

agent_graph.dart

class AgentGraph {
  final String id;
  final String entryNodeId;
  final Map<String, AgentNode> nodes;

  const AgentGraph({
    required this.id,
    required this.entryNodeId,
    required this.nodes,
  });

  AgentNode node(String id) => nodes[id]!;
}

tool_registry.dart

class ToolRegistry {
  final Map<String, AgentTool> _tools;
  ToolRegistry(Iterable<AgentTool> tools)
      : _tools = {for (final t in tools) t.id: t};

  List<ToolSpec> get specs => _tools.values.map((t) => t.spec).toList();

  AgentTool require(String id) => _tools[id]!;
}

agent_context.dart

class AgentContext {
  final ModelRuntime model;
  final ToolRegistry tools;
  final void Function(AgentEvent event) emit;
  final AgentPolicies policies;

  const AgentContext({
    required this.model,
    required this.tools,
    required this.emit,
    required this.policies,
  });
}

14. How showGraph should work in this architecture

This is where the design really pays off.

Flow

  1. model calls data tools
  2. model calls show_graph
  3. ShowGraphTool returns a ChartArtifact
  4. runtime emits:
    • AgentArtifactReady(ChartArtifact(...))
    • maybe AgentChartData(...) for backward compatibility
  5. UI renders chart inline in chat

Example tool

class ShowGraphTool implements AgentTool {
  `@override`
  String get id => 'show_graph';

  `@override`
  ToolMetadata get metadata => const ToolMetadata(
    displayName: 'Show graph',
    kind: ToolKind.ui,
    readOnly: true,
    outputKind: AgentArtifactKind.chart,
  );

  `@override`
  ToolSpec get spec => ToolSpec.function(...);

  `@override`
  Future<ToolResult> execute(ToolExecutionContext ctx) async {
    final spec = {
      'type': 'line',
      'title': 'Bench Press Progress',
      'x': [...],
      'series': [...],
    };

    return ToolResult(
      data: {'chart_spec': spec},
      artifacts: [
        ChartArtifact(
          chartType: 'line',
          title: 'Bench Press Progress',
          spec: spec,
        ),
      ],
    );
  }
}

That is much cleaner than passing opaque maps around the whole stack.


15. Migration plan for this repo

I would do this in 4 incremental PRs.

PR 1 — Provider/runtime separation

Goal: make orchestration possible without breaking UI

  • Introduce ModelRuntime + ModelStep
  • adapt GeminiAiService into GeminiProviderAdapter
  • keep current AgentOrchestrator, but make it depend on ModelRuntime

PR 2 — Tool registry + typed tools

Goal: remove tool semantics from orchestrator

  • add AgentTool, ToolRegistry, ToolResult
  • refactor CoachToolService into execution backend + tool adapters
  • move _toolLabel(...) into tool metadata

PR 3 — Graph runtime

Goal: replace heuristics with explicit state machine

  • add AgentRuntime, AgentGraph, AgentNode, AgentRunState
  • implement coachGraph
  • have AiCoachViewModel call runtime instead of orchestrator

PR 4 — Interrupts + artifacts

Goal: support optimizer and future GenUI

  • add AgentInterrupt
  • move ask_user_questions into interrupt/resume flow
  • add AgentArtifact
  • add ChartArtifact + showGraph support

16. My recommended “minimum viable shared base”

If you want the smallest architecture that is still worth sharing across apps, it is this:

Required

  • ModelRuntime
  • AgentRuntime
  • AgentGraph
  • AgentRunState
  • AgentNode
  • ToolRegistry
  • AgentTool
  • ToolResult
  • AgentArtifact
  • AgentInterrupt

Optional for later

  • durable checkpointing
  • persisted runtime state
  • multi-agent planners
  • graph composition DSL
  • visual graph inspector

That gives you a reusable core without overengineering too early.


17. Bottom-line recommendation

If this repo is going to be the seed for multiple apps, I would make this the official direction:

Use ConversationManager for persistence, AgentRuntime for orchestration, ModelRuntime for provider access, and ToolRegistry + AgentArtifact for extensibility.

That gives you:

  • LangGraph-like explicit control flow
  • reusable tooling across apps
  • human-in-the-loop support
  • future chart/GenUI support
  • much cleaner testing boundaries

If you want, I can next turn this into a repo-specific implementation plan with:

  1. exact new files to add
  2. which current files to rename/split
  3. starter Dart skeletons for each class
    so you can use it as the blueprint for the next PR.

@Devasy

Devasy commented Jun 27, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai you can give repo-specific implementation plan with:

exact new files to add
which current files to rename/split
starter Dart skeletons for each class
so you can use it as the blueprint for the next PR.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Relevant AI/runtime file tree =="
fd . workout-logger/lib/services/ai workout-logger/lib/services/interfaces workout-logger/lib/services/managers workout-logger/lib/models workout-logger/lib/viewmodels | sed 's#^\./##' | sort

echo
echo "== AST outlines for exact files we may rename/split =="
for f in \
  workout-logger/lib/services/interfaces/ai_service_interface.dart \
  workout-logger/lib/services/ai/gemini_ai_service.dart \
  workout-logger/lib/services/ai/agent_orchestrator.dart \
  workout-logger/lib/services/ai/agent_event.dart \
  workout-logger/lib/services/ai/coach_tool_service.dart \
  workout-logger/lib/services/managers/conversation_manager.dart \
  workout-logger/lib/models/models.dart \
  workout-logger/lib/viewmodels/ai_coach_view_model.dart \
  workout-logger/lib/viewmodels/routine_optimizer_view_model.dart
do
  if [ -f "$f" ]; then
    echo "--- OUTLINE: $f ---"
    ast-grep outline "$f" --view expanded || true
    echo
  fi
done

echo "== Targeted excerpts for signatures and responsibilities =="
for f in \
  workout-logger/lib/services/interfaces/ai_service_interface.dart \
  workout-logger/lib/services/ai/gemini_ai_service.dart \
  workout-logger/lib/services/ai/agent_orchestrator.dart \
  workout-logger/lib/services/ai/coach_tool_service.dart \
  workout-logger/lib/services/managers/conversation_manager.dart \
  workout-logger/lib/models/models.dart \
  workout-logger/lib/viewmodels/ai_coach_view_model.dart \
  workout-logger/lib/viewmodels/routine_optimizer_view_model.dart
do
  if [ -f "$f" ]; then
    echo "--- FILE: $f (lines 1-260) ---"
    sed -n '1,260p' "$f"
    echo
  fi
done

echo "== Focused searches for core symbols =="
rg -n -C2 \
  'abstract class IAiService|streamCoachReply\s*\(|buildTools\s*\(|handleCall\s*\(|askUserQuestionsDeclaration|appendMessage\s*\(|startNewConversation\s*\(|selectConversation\s*\(|class ChatMessage|class Conversation|enum MessageRole' \
  workout-logger/lib

Length of output: 78113



@Devasy based on the current repo shape, here’s the repo-specific blueprint I’d use for the follow-up to #57.

  • Add a new provider/runtime/tools foundation under workout-logger/lib/services/ai/
  • Keep ConversationManager and persisted ChatMessage / Conversation unchanged
  • Split GeminiAiService, AgentOrchestrator, and CoachToolService by responsibility
  • Migrate AiCoachViewModel and RoutineOptimizerViewModel to the new runtime with minimal UI churn
  • Preserve current behavior first; then add artifacts/interrupts cleanly

1. What I would do in the next PR

I would keep the next PR focused on this:

  1. Introduce a lower-level model provider contract
  2. Introduce a runtime that owns the loop
  3. Split tools into typed units + registry
  4. Keep ConversationManager as-is
  5. Migrate both VMs
  6. Leave AgentEvent in place for UI compatibility

That gets you the scalable core without rewriting the whole app.


2. Exact new files to add

A. Provider layer

Add these files:

workout-logger/lib/services/ai/provider/model_message.dart
workout-logger/lib/services/ai/provider/model_step.dart
workout-logger/lib/services/ai/provider/model_runtime.dart
workout-logger/lib/services/ai/provider/gemini_provider_adapter.dart

B. Runtime layer

Add these files:

workout-logger/lib/services/ai/runtime/agent_run_input.dart
workout-logger/lib/services/ai/runtime/agent_run_state.dart
workout-logger/lib/services/ai/runtime/agent_context.dart
workout-logger/lib/services/ai/runtime/agent_node.dart
workout-logger/lib/services/ai/runtime/agent_graph.dart
workout-logger/lib/services/ai/runtime/agent_runtime.dart
workout-logger/lib/services/ai/runtime/default_agent_runtime.dart
workout-logger/lib/services/ai/runtime/agent_artifact.dart
workout-logger/lib/services/ai/runtime/agent_interrupt.dart

C. Tools layer

Add these files:

workout-logger/lib/services/ai/tools/tool_spec.dart
workout-logger/lib/services/ai/tools/tool_execution_context.dart
workout-logger/lib/services/ai/tools/tool_result.dart
workout-logger/lib/services/ai/tools/agent_tool.dart
workout-logger/lib/services/ai/tools/tool_registry.dart
workout-logger/lib/services/ai/tools/builtins/workout_query_tools.dart
workout-logger/lib/services/ai/tools/builtins/routine_tools.dart
workout-logger/lib/services/ai/tools/builtins/ask_user_questions_tool.dart

D. Graph definitions

Add these files:

workout-logger/lib/services/ai/graphs/coach_graph.dart
workout-logger/lib/services/ai/graphs/optimizer_graph.dart

E. Optional UI adapter layer

Useful if you want to keep UI-facing events decoupled:

workout-logger/lib/services/ai/ui/agent_event_mapper.dart

F. New tests

Add these tests:

workout-logger/test/services/ai/provider/gemini_provider_adapter_test.dart
workout-logger/test/services/ai/runtime/default_agent_runtime_test.dart
workout-logger/test/services/ai/tools/tool_registry_test.dart
workout-logger/test/services/ai/graphs/coach_graph_test.dart
workout-logger/test/services/ai/graphs/optimizer_graph_test.dart

3. Which current files to rename/split

Here’s the repo-specific split plan.

workout-logger/lib/services/interfaces/ai_service_interface.dart

Current problem: IAiService mixes:

  • agent chat streaming
  • tool loop ownership
  • program generation
  • insights generation

Plan:

  • Keep IAiService temporarily for:
    • generateProgram(...)
    • generateWeeklyInsights(...)
    • generateInsight(...)
  • Stop using IAiService.streamCoachReply(...) from the agent runtime path
  • Add the new provider contract in workout-logger/lib/services/ai/provider/model_runtime.dart

Do not rename this file in the same PR.
Just reduce its responsibilities over time.


workout-logger/lib/services/ai/gemini_ai_service.dart

Current problem: it owns too much:

  • transport
  • retries
  • tool loop
  • agent streaming
  • non-agent generation methods
  • token accounting

Plan: split

  • Move model-step streaming into:
    • workout-logger/lib/services/ai/provider/gemini_provider_adapter.dart
  • Keep in workout-logger/lib/services/ai/gemini_ai_service.dart:
    • API key/model config
    • token accounting
    • generateProgram(...)
    • generateWeeklyInsights(...)
    • generateInsight(...)

Recommendation: keep the filename gemini_ai_service.dart for now to avoid noisy churn.


workout-logger/lib/services/ai/agent_orchestrator.dart

Current problem: orchestration policy is heuristic and external to the real provider loop.

Plan:

  • Replace the internals with a thin compatibility façade over DefaultAgentRuntime
  • Keep the class for one PR so AiCoachViewModel and RoutineOptimizerViewModel can migrate gradually
  • After both VMs use AgentRuntime directly, delete AgentOrchestrator

Net effect: this file becomes temporary migration glue, not the final foundation.


workout-logger/lib/services/ai/coach_tool_service.dart

Current problem: it currently mixes:

  • tool schema declaration
  • dispatch
  • argument parsing
  • backend calls
  • product-specific tool labels/semantics

Plan: split

  • Keep CoachToolService as the backend data access / helper service
  • Move tool definitions into:
    • workout-logger/lib/services/ai/tools/builtins/workout_query_tools.dart
    • workout-logger/lib/services/ai/tools/builtins/routine_tools.dart
    • workout-logger/lib/services/ai/tools/builtins/ask_user_questions_tool.dart
  • Registry lives in:
    • workout-logger/lib/services/ai/tools/tool_registry.dart

Important: I would not rename coach_tool_service.dart in the same PR.
First split responsibilities; rename later only if still needed.


workout-logger/lib/viewmodels/ai_coach_view_model.dart

Plan:

  • Replace _orchestrator.orchestrate(...) with _runtime.run(...)
  • Inject:
    • AgentRuntime
    • ToolRegistry
    • AgentGraph or a concrete coachGraph

workout-logger/lib/viewmodels/routine_optimizer_view_model.dart

Plan:

  • Replace _orchestrator.orchestrate(...) with _runtime.run(...)
  • Replace _handleAskUserQuestions(...) special casing with runtime interrupts
  • Keep PendingQuestions and AnswerSpec from workout-logger/lib/models/models.dart

workout-logger/lib/services/managers/conversation_manager.dart

Keep as-is.

This is already a good boundary:

  • persists only transcript
  • does not know about tools/runtime/provider internals

That’s exactly what you want.


4. New dependency wiring in workout-logger/lib/main.dart

Target provider tree for the next PR:

Provider<ModelRuntime>(
  create: (ctx) => GeminiProviderAdapter(
    gemini: ctx.read<GeminiAiService>(),
  ),
),
Provider<ToolRegistry>(
  create: (ctx) => ToolRegistry([
    ...buildWorkoutQueryTools(ctx.read<CoachToolService>()),
    ...buildRoutineTools(ctx.read<CoachToolService>()),
    buildAskUserQuestionsTool(),
  ]),
),
Provider<AgentRuntime>(
  create: (ctx) => DefaultAgentRuntime(
    model: ctx.read<ModelRuntime>(),
    tools: ctx.read<ToolRegistry>(),
  ),
),
Provider<AgentGraph>(
  create: (_) => buildCoachGraph(), // or named provider per screen
),

For optimizer, either:

  • provide a second graph instance, or
  • inject the graph directly into the optimizer VM constructor.

5. Starter Dart skeletons for each new class

Below are starter skeletons, not final implementations.


workout-logger/lib/services/ai/provider/model_message.dart

class ModelMessage {
  final String role; // system | user | model | tool
  final String text;
  final String? toolCallId;
  final Map<String, Object?>? toolPayload;

  const ModelMessage({
    required this.role,
    required this.text,
    this.toolCallId,
    this.toolPayload,
  });

  ModelMessage copyWith({
    String? role,
    String? text,
    String? toolCallId,
    Map<String, Object?>? toolPayload,
  }) {
    return ModelMessage(
      role: role ?? this.role,
      text: text ?? this.text,
      toolCallId: toolCallId ?? this.toolCallId,
      toolPayload: toolPayload ?? this.toolPayload,
    );
  }
}

workout-logger/lib/services/ai/provider/model_step.dart

sealed class ModelStep {
  const ModelStep();
}

class ModelTextDelta extends ModelStep {
  final String text;
  const ModelTextDelta(this.text);
}

class ModelToolCall extends ModelStep {
  final String callId;
  final String toolName;
  final Map<String, Object?> args;

  const ModelToolCall({
    required this.callId,
    required this.toolName,
    required this.args,
  });
}

class ModelFinish extends ModelStep {
  final String reason; // stop, tool_calls, max_tokens, safety
  const ModelFinish(this.reason);
}

workout-logger/lib/services/ai/provider/model_runtime.dart

import 'model_message.dart';
import 'model_step.dart';
import '../tools/tool_spec.dart';

abstract class ModelRuntime {
  bool get isConfigured;
  String get currentModel;

  Stream<ModelStep> streamStep({
    required String systemPrompt,
    required List<ModelMessage> messages,
    required List<ToolSpec> tools,
  });
}

workout-logger/lib/services/ai/provider/gemini_provider_adapter.dart

import 'package:google_generative_ai/google_generative_ai.dart'
    show Content, FunctionCall, FunctionDeclaration, Part, TextPart, Tool;

import '../gemini_ai_service.dart';
import '../tools/tool_spec.dart';
import 'model_message.dart';
import 'model_runtime.dart';
import 'model_step.dart';

class GeminiProviderAdapter implements ModelRuntime {
  final GeminiAiService _gemini;

  GeminiProviderAdapter({required GeminiAiService gemini}) : _gemini = gemini;

  `@override`
  bool get isConfigured => _gemini.isConfigured;

  `@override`
  String get currentModel => _gemini.currentModel;

  `@override`
  Stream<ModelStep> streamStep({
    required String systemPrompt,
    required List<ModelMessage> messages,
    required List<ToolSpec> tools,
  }) async* {
    // Phase 1 implementation path:
    // 1. Convert ModelMessage -> Gemini Content
    // 2. Convert ToolSpec -> Gemini Tool / FunctionDeclaration
    // 3. Call a new low-level streaming method extracted from GeminiAiService
    // 4. Yield ModelTextDelta / ModelToolCall / ModelFinish

    throw UnimplementedError();
  }
}

workout-logger/lib/services/ai/tools/tool_spec.dart

class ToolSpec {
  final String id;
  final String description;
  final Map<String, Object?> jsonSchema;

  const ToolSpec({
    required this.id,
    required this.description,
    required this.jsonSchema,
  });
}

workout-logger/lib/services/ai/tools/tool_execution_context.dart

import '../../runtime/agent_run_state.dart';

class ToolExecutionContext {
  final AgentRunState state;
  final Map<String, Object?> args;

  const ToolExecutionContext({
    required this.state,
    required this.args,
  });
}

workout-logger/lib/services/ai/tools/tool_result.dart

import '../runtime/agent_artifact.dart';

class ToolResult {
  final Map<String, Object?> data;
  final List<AgentArtifact> artifacts;

  const ToolResult({
    this.data = const {},
    this.artifacts = const [],
  });
}

workout-logger/lib/services/ai/tools/agent_tool.dart

import 'tool_execution_context.dart';
import 'tool_result.dart';
import 'tool_spec.dart';

enum ToolKind {
  query,
  mutation,
  interrupt,
  ui,
}

class ToolMetadata {
  final String displayName;
  final ToolKind kind;
  final bool readOnly;
  final String? progressLabel;

  const ToolMetadata({
    required this.displayName,
    required this.kind,
    required this.readOnly,
    this.progressLabel,
  });
}

abstract class AgentTool {
  String get id;
  ToolMetadata get metadata;
  ToolSpec get spec;

  Future<ToolResult> execute(ToolExecutionContext context);
}

workout-logger/lib/services/ai/tools/tool_registry.dart

import 'agent_tool.dart';
import 'tool_spec.dart';

class ToolRegistry {
  final Map<String, AgentTool> _tools;

  ToolRegistry(Iterable<AgentTool> tools)
      : _tools = {for (final tool in tools) tool.id: tool};

  List<ToolSpec> get specs =>
      _tools.values.map((tool) => tool.spec).toList(growable: false);

  AgentTool require(String id) {
    final tool = _tools[id];
    if (tool == null) {
      throw StateError('Unknown tool: $id');
    }
    return tool;
  }

  bool contains(String id) => _tools.containsKey(id);
}

workout-logger/lib/services/ai/runtime/agent_artifact.dart

sealed class AgentArtifact {
  const AgentArtifact();
}

class TextArtifact extends AgentArtifact {
  final String markdown;
  const TextArtifact(this.markdown);
}

class ChartArtifact extends AgentArtifact {
  final String chartType;
  final String title;
  final Map<String, Object?> spec;

  const ChartArtifact({
    required this.chartType,
    required this.title,
    required this.spec,
  });
}

workout-logger/lib/services/ai/runtime/agent_interrupt.dart

import '../../../models/models.dart';

sealed class AgentInterrupt {
  const AgentInterrupt();
}

class AwaitUserQuestions extends AgentInterrupt {
  final PendingQuestions questions;
  const AwaitUserQuestions(this.questions);
}

workout-logger/lib/services/ai/runtime/agent_run_input.dart

import '../../../models/models.dart';

class AgentRunInput {
  final String runId;
  final String userMessage;
  final String systemPrompt;
  final List<ChatMessage> transcript;

  const AgentRunInput({
    required this.runId,
    required this.userMessage,
    required this.systemPrompt,
    required this.transcript,
  });
}

workout-logger/lib/services/ai/runtime/agent_run_state.dart

import '../provider/model_message.dart';
import 'agent_artifact.dart';

class AgentRunState {
  final String runId;
  final String graphId;
  final String userMessage;
  final List<ModelMessage> messages;
  final List<PendingToolCall> pendingToolCalls;
  final List<AgentArtifact> artifacts;
  final String? statusText;
  final Set<String> activeToolIds;
  final Map<String, Object?> memory;
  final bool isComplete;

  const AgentRunState({
    required this.runId,
    required this.graphId,
    required this.userMessage,
    required this.messages,
    required this.pendingToolCalls,
    required this.artifacts,
    required this.activeToolIds,
    required this.memory,
    this.statusText,
    this.isComplete = false,
  });

  AgentRunState copyWith({
    String? runId,
    String? graphId,
    String? userMessage,
    List<ModelMessage>? messages,
    List<PendingToolCall>? pendingToolCalls,
    List<AgentArtifact>? artifacts,
    String? statusText,
    Set<String>? activeToolIds,
    Map<String, Object?>? memory,
    bool? isComplete,
  }) {
    return AgentRunState(
      runId: runId ?? this.runId,
      graphId: graphId ?? this.graphId,
      userMessage: userMessage ?? this.userMessage,
      messages: messages ?? this.messages,
      pendingToolCalls: pendingToolCalls ?? this.pendingToolCalls,
      artifacts: artifacts ?? this.artifacts,
      statusText: statusText ?? this.statusText,
      activeToolIds: activeToolIds ?? this.activeToolIds,
      memory: memory ?? this.memory,
      isComplete: isComplete ?? this.isComplete,
    );
  }

  factory AgentRunState.initial({
    required String runId,
    required String graphId,
    required String userMessage,
    required List<ModelMessage> messages,
  }) {
    return AgentRunState(
      runId: runId,
      graphId: graphId,
      userMessage: userMessage,
      messages: messages,
      pendingToolCalls: const [],
      artifacts: const [],
      activeToolIds: const {},
      memory: const {},
    );
  }
}

class PendingToolCall {
  final String callId;
  final String toolName;
  final Map<String, Object?> args;

  const PendingToolCall({
    required this.callId,
    required this.toolName,
    required this.args,
  });
}

workout-logger/lib/services/ai/runtime/agent_context.dart

import '../provider/model_runtime.dart';
import '../tools/tool_registry.dart';
import '../../ai/agent_event.dart';

class AgentContext {
  final ModelRuntime model;
  final ToolRegistry tools;
  final void Function(AgentEvent event) emit;

  const AgentContext({
    required this.model,
    required this.tools,
    required this.emit,
  });
}

workout-logger/lib/services/ai/runtime/agent_node.dart

import 'agent_context.dart';
import 'agent_interrupt.dart';
import 'agent_run_state.dart';

sealed class AgentNodeResult {
  const AgentNodeResult();
}

class NextNode extends AgentNodeResult {
  final String nodeId;
  final AgentRunState state;

  const NextNode({
    required this.nodeId,
    required this.state,
  });
}

class CompleteRun extends AgentNodeResult {
  final AgentRunState state;
  const CompleteRun(this.state);
}

class InterruptRun extends AgentNodeResult {
  final AgentRunState state;
  final AgentInterrupt interrupt;

  const InterruptRun({
    required this.state,
    required this.interrupt,
  });
}

abstract class AgentNode {
  String get id;

  Future<AgentNodeResult> execute(
    AgentContext context,
    AgentRunState state,
  );
}

workout-logger/lib/services/ai/runtime/agent_graph.dart

import 'agent_node.dart';

class AgentGraph {
  final String id;
  final String entryNodeId;
  final Map<String, AgentNode> nodes;

  const AgentGraph({
    required this.id,
    required this.entryNodeId,
    required this.nodes,
  });

  AgentNode node(String id) {
    final node = nodes[id];
    if (node == null) {
      throw StateError('Unknown node: $id in graph $this.id');
    }
    return node;
  }
}

workout-logger/lib/services/ai/runtime/agent_runtime.dart

import '../../ai/agent_event.dart';
import 'agent_graph.dart';
import 'agent_run_input.dart';

abstract class AgentRuntime {
  bool get isConfigured;

  Stream<AgentEvent> run({
    required AgentGraph graph,
    required AgentRunInput input,
  });

  Future<void> resume({
    required String runId,
    required Map<String, Object?> payload,
  });
}

workout-logger/lib/services/ai/runtime/default_agent_runtime.dart

import 'dart:async';

import '../../ai/agent_event.dart';
import '../provider/model_message.dart';
import '../provider/model_runtime.dart';
import '../tools/tool_registry.dart';
import 'agent_context.dart';
import 'agent_graph.dart';
import 'agent_node.dart';
import 'agent_run_input.dart';
import 'agent_run_state.dart';
import 'agent_runtime.dart';

class DefaultAgentRuntime implements AgentRuntime {
  final ModelRuntime _model;
  final ToolRegistry _tools;

  final Map<String, Completer<Map<String, Object?>>> _resumeWaiters = {};

  DefaultAgentRuntime({
    required ModelRuntime model,
    required ToolRegistry tools,
  })  : _model = model,
        _tools = tools;

  `@override`
  bool get isConfigured => _model.isConfigured;

  `@override`
  Stream<AgentEvent> run({
    required AgentGraph graph,
    required AgentRunInput input,
  }) async* {
    final controller = StreamController<AgentEvent>();
    final ctx = AgentContext(
      model: _model,
      tools: _tools,
      emit: controller.add,
    );

    unawaited(() async {
      try {
        var state = AgentRunState.initial(
          runId: input.runId,
          graphId: graph.id,
          userMessage: input.userMessage,
          messages: [
            for (final msg in input.transcript)
              ModelMessage(role: msg.role, text: msg.text),
          ],
        );

        var currentNodeId = graph.entryNodeId;

        while (true) {
          final result = await graph.node(currentNodeId).execute(ctx, state);

          switch (result) {
            case NextNode(:final nodeId, :final state):
              currentNodeId = nodeId;
              state = state;
            case CompleteRun(:final state):
              controller.add(const AgentStatusUpdate(''));
              await controller.close();
              return;
            case InterruptRun():
              // Phase 1: surface interrupt event once added to AgentEvent.
              await controller.close();
              return;
          }
        }
      } catch (e) {
        controller.add(AgentError('$e'));
        await controller.close();
      }
    }());

    yield* controller.stream;
  }

  `@override`
  Future<void> resume({
    required String runId,
    required Map<String, Object?> payload,
  }) async {
    final completer = _resumeWaiters[runId];
    if (completer == null || completer.isCompleted) return;
    completer.complete(payload);
  }
}

6. Minimal node implementations to add in the same PR

You need a few concrete nodes too. I’d put them inside:

workout-logger/lib/services/ai/runtime/nodes/prepare_turn_node.dart
workout-logger/lib/services/ai/runtime/nodes/model_turn_node.dart
workout-logger/lib/services/ai/runtime/nodes/execute_tools_node.dart
workout-logger/lib/services/ai/runtime/nodes/complete_turn_node.dart

prepare_turn_node.dart

import '../agent_context.dart';
import '../agent_node.dart';
import '../agent_run_state.dart';

class PrepareTurnNode implements AgentNode {
  `@override`
  String get id => 'prepare_turn';

  `@override`
  Future<AgentNodeResult> execute(
    AgentContext context,
    AgentRunState state,
  ) async {
    context.emit(const AgentStatusUpdate('Thinking…'));
    return NextNode(
      nodeId: 'model_turn',
      state: state,
    );
  }
}

model_turn_node.dart

import '../../agent_event.dart';
import '../../provider/model_step.dart';
import '../agent_context.dart';
import '../agent_node.dart';
import '../agent_run_state.dart';

class ModelTurnNode implements AgentNode {
  final String systemPrompt;
  final List<String> allowedToolIds;

  ModelTurnNode({
    required this.systemPrompt,
    required this.allowedToolIds,
  });

  `@override`
  String get id => 'model_turn';

  `@override`
  Future<AgentNodeResult> execute(
    AgentContext context,
    AgentRunState state,
  ) async {
    final pendingCalls = <PendingToolCall>[];
    final buffer = StringBuffer();

    await for (final step in context.model.streamStep(
      systemPrompt: systemPrompt,
      messages: state.messages,
      tools: [
        for (final toolId in allowedToolIds) context.tools.require(toolId).spec,
      ],
    )) {
      switch (step) {
        case ModelTextDelta(:final text):
          buffer.write(text);
          context.emit(AgentTextChunk(text));

        case ModelToolCall(:final callId, :final toolName, :final args):
          pendingCalls.add(
            PendingToolCall(
              callId: callId,
              toolName: toolName,
              args: args,
            ),
          );

        case ModelFinish():
          break;
      }
    }

    final nextState = state.copyWith(
      pendingToolCalls: pendingCalls,
      messages: [
        ...state.messages,
        if (buffer.isNotEmpty)
          ModelMessage(role: 'model', text: buffer.toString()),
      ],
    );

    if (pendingCalls.isNotEmpty) {
      return NextNode(nodeId: 'execute_tools', state: nextState);
    }

    return NextNode(nodeId: 'complete_turn', state: nextState);
  }
}

execute_tools_node.dart

import '../../agent_event.dart';
import '../../provider/model_message.dart';
import '../../tools/tool_execution_context.dart';
import '../agent_context.dart';
import '../agent_node.dart';
import '../agent_run_state.dart';

class ExecuteToolsNode implements AgentNode {
  `@override`
  String get id => 'execute_tools';

  `@override`
  Future<AgentNodeResult> execute(
    AgentContext context,
    AgentRunState state,
  ) async {
    final nextMessages = [...state.messages];
    final remaining = <PendingToolCall>[];

    for (final call in state.pendingToolCalls) {
      final tool = context.tools.require(call.toolName);

      context.emit(AgentToolActivity(
        call.toolName,
        isStart: true,
        label: tool.metadata.progressLabel ?? tool.metadata.displayName,
      ));

      final result = await tool.execute(
        ToolExecutionContext(
          state: state,
          args: call.args,
        ),
      );

      context.emit(AgentToolActivity(
        call.toolName,
        isStart: false,
        label: tool.metadata.progressLabel ?? tool.metadata.displayName,
      ));

      nextMessages.add(
        ModelMessage(
          role: 'tool',
          text: '',
          toolCallId: call.callId,
          toolPayload: result.data,
        ),
      );
    }

    return NextNode(
      nodeId: 'model_turn',
      state: state.copyWith(
        pendingToolCalls: remaining,
        messages: nextMessages,
      ),
    );
  }
}

complete_turn_node.dart

import '../agent_context.dart';
import '../agent_node.dart';
import '../agent_run_state.dart';

class CompleteTurnNode implements AgentNode {
  `@override`
  String get id => 'complete_turn';

  `@override`
  Future<AgentNodeResult> execute(
    AgentContext context,
    AgentRunState state,
  ) async {
    return CompleteRun(state.copyWith(isComplete: true));
  }
}

7. Built-in tool skeletons


workout-logger/lib/services/ai/tools/builtins/workout_query_tools.dart

import '../../coach_tool_service.dart';
import '../agent_tool.dart';
import '../tool_execution_context.dart';
import '../tool_result.dart';
import '../tool_spec.dart';

List<AgentTool> buildWorkoutQueryTools(CoachToolService backend) => [
      GetExercisePerformanceTool(backend),
      GetWorkoutsInRangeTool(backend),
      GetRoutinePerformanceTool(backend),
      GetPersonalRecordsTool(backend),
      GetGoalProgressTool(backend),
      GetMuscleRecoveryTool(backend),
      GetAllRoutinesTool(backend),
    ];

class GetExercisePerformanceTool implements AgentTool {
  final CoachToolService _backend;

  GetExercisePerformanceTool(this._backend);

  `@override`
  String get id => 'get_exercise_performance';

  `@override`
  ToolMetadata get metadata => const ToolMetadata(
        displayName: 'Exercise performance',
        kind: ToolKind.query,
        readOnly: true,
        progressLabel: 'exercise performance',
      );

  `@override`
  ToolSpec get spec => const ToolSpec(
        id: 'get_exercise_performance',
        description: 'Get progress for a named exercise.',
        jsonSchema: {
          'type': 'object',
          'properties': {
            'exercise_name': {'type': 'string'},
            'days': {'type': 'integer'},
            'limit': {'type': 'integer'},
          },
          'required': ['exercise_name'],
        },
      );

  `@override`
  Future<ToolResult> execute(ToolExecutionContext context) async {
    // Phase 1: call extracted backend helper from CoachToolService.
    throw UnimplementedError();
  }
}

// Repeat same pattern for:
// - GetWorkoutsInRangeTool
// - GetRoutinePerformanceTool
// - GetPersonalRecordsTool
// - GetGoalProgressTool
// - GetMuscleRecoveryTool
// - GetAllRoutinesTool

workout-logger/lib/services/ai/tools/builtins/routine_tools.dart

import '../../coach_tool_service.dart';
import '../agent_tool.dart';
import '../tool_execution_context.dart';
import '../tool_result.dart';
import '../tool_spec.dart';

List<AgentTool> buildRoutineTools(CoachToolService backend) => [
      CreateRoutineTool(backend),
      UpdateRoutineTool(backend),
      AddCustomExerciseTool(backend),
    ];

class CreateRoutineTool implements AgentTool {
  final CoachToolService _backend;

  CreateRoutineTool(this._backend);

  `@override`
  String get id => 'create_routine';

  `@override`
  ToolMetadata get metadata => const ToolMetadata(
        displayName: 'Create routine',
        kind: ToolKind.mutation,
        readOnly: false,
        progressLabel: 'creating routine',
      );

  `@override`
  ToolSpec get spec => const ToolSpec(
        id: 'create_routine',
        description: 'Create a new routine.',
        jsonSchema: {
          'type': 'object',
          'properties': {
            'name': {'type': 'string'},
            'exercise_names': {
              'type': 'array',
              'items': {'type': 'string'},
            },
          },
          'required': ['name', 'exercise_names'],
        },
      );

  `@override`
  Future<ToolResult> execute(ToolExecutionContext context) async {
    throw UnimplementedError();
  }
}

workout-logger/lib/services/ai/tools/builtins/ask_user_questions_tool.dart

import '../../../models/models.dart';
import '../agent_tool.dart';
import '../tool_execution_context.dart';
import '../tool_result.dart';
import '../tool_spec.dart';
import '../../runtime/agent_interrupt.dart';

AgentTool buildAskUserQuestionsTool() => AskUserQuestionsTool();

class AskUserQuestionsTool implements AgentTool {
  `@override`
  String get id => 'ask_user_questions';

  `@override`
  ToolMetadata get metadata => const ToolMetadata(
        displayName: 'Ask user questions',
        kind: ToolKind.interrupt,
        readOnly: true,
        progressLabel: 'preparing questions',
      );

  `@override`
  ToolSpec get spec => const ToolSpec(
        id: 'ask_user_questions',
        description: 'Ask the user 1-3 clarifying questions.',
        jsonSchema: {
          'type': 'object',
          'properties': {
            'preamble': {'type': 'string'},
            'questions': {'type': 'array'},
          },
          'required': ['questions'],
        },
      );

  `@override`
  Future<ToolResult> execute(ToolExecutionContext context) async {
    // Phase 1 option:
    // return a ToolResult data payload and let the runtime convert it to an interrupt.
    // Phase 2 option:
    // allow tool execution to raise an AgentInterrupt directly.
    throw UnimplementedError();
  }
}

8. Graph builders


workout-logger/lib/services/ai/graphs/coach_graph.dart

import '../runtime/agent_graph.dart';
import '../runtime/nodes/complete_turn_node.dart';
import '../runtime/nodes/execute_tools_node.dart';
import '../runtime/nodes/model_turn_node.dart';
import '../runtime/nodes/prepare_turn_node.dart';

AgentGraph buildCoachGraph({
  required String systemPrompt,
}) {
  return AgentGraph(
    id: 'coach_graph',
    entryNodeId: 'prepare_turn',
    nodes: {
      'prepare_turn': PrepareTurnNode(),
      'model_turn': ModelTurnNode(
        systemPrompt: systemPrompt,
        allowedToolIds: const [
          'get_exercise_performance',
          'get_workouts_in_range',
          'get_routine_performance',
          'get_personal_records',
          'get_goal_progress',
          'get_muscle_recovery',
          'get_all_routines',
          'create_routine',
          'update_routine',
          'add_custom_exercise',
        ],
      ),
      'execute_tools': ExecuteToolsNode(),
      'complete_turn': CompleteTurnNode(),
    },
  );
}

workout-logger/lib/services/ai/graphs/optimizer_graph.dart

import '../runtime/agent_graph.dart';
import '../runtime/nodes/complete_turn_node.dart';
import '../runtime/nodes/execute_tools_node.dart';
import '../runtime/nodes/model_turn_node.dart';
import '../runtime/nodes/prepare_turn_node.dart';

AgentGraph buildOptimizerGraph({
  required String systemPrompt,
}) {
  return AgentGraph(
    id: 'optimizer_graph',
    entryNodeId: 'prepare_turn',
    nodes: {
      'prepare_turn': PrepareTurnNode(),
      'model_turn': ModelTurnNode(
        systemPrompt: systemPrompt,
        allowedToolIds: const [
          'get_exercise_performance',
          'get_workouts_in_range',
          'get_routine_performance',
          'get_personal_records',
          'get_goal_progress',
          'get_muscle_recovery',
          'get_all_routines',
          'create_routine',
          'update_routine',
          'add_custom_exercise',
          'ask_user_questions',
        ],
      ),
      'execute_tools': ExecuteToolsNode(),
      'complete_turn': CompleteTurnNode(),
    },
  );
}

9. VM migration shape

workout-logger/lib/viewmodels/ai_coach_view_model.dart

Constructor target:

final AgentRuntime _runtime;
final ToolRegistry _tools;

AiCoachViewModel({
  required AgentRuntime runtime,
  required ToolRegistry tools,
  required ConversationManager conversations,
  required SettingsProvider settings,
})  : _runtime = runtime,
      _tools = tools,
      _conversations = conversations,
      _settings = settings;

Send target:

await for (final event in _runtime.run(
  graph: buildCoachGraph(systemPrompt: systemPrompt),
  input: AgentRunInput(
    runId: const Uuid().v4(),
    userMessage: trimmed,
    systemPrompt: systemPrompt,
    transcript: _conversations.activeMessages,
  ),
)) {
  // existing AgentEvent switch stays mostly unchanged
}

workout-logger/lib/viewmodels/routine_optimizer_view_model.dart

Same change, but:

await for (final event in _runtime.run(
  graph: buildOptimizerGraph(systemPrompt: systemPrompt),
  input: AgentRunInput(
    runId: const Uuid().v4(),
    userMessage: trimmed,
    systemPrompt: systemPrompt,
    transcript: _conversations.activeMessages,
  ),
)) {
  // existing switch + new interrupt handling
}

Then replace _handleAskUserQuestions(...) with:

  • runtime interrupt event
  • submitAnswers(...) calls _runtime.resume(...)

That removes the current VM-owned pause/resume orchestration.


10. What stays unchanged in the next PR

These should stay stable:

  • workout-logger/lib/services/managers/conversation_manager.dart
  • workout-logger/lib/models/models.dart for:
    • ChatMessage
    • Conversation
    • PendingQuestions
    • AnswerSpec
    • Routine
  • workout-logger/lib/services/ai/agent_event.dart
  • workout-logger/lib/services/ai/retry_policy.dart

You can expand AgentEvent later, but don’t block the runtime split on that.


11. Recommended implementation order

Commit 1

Add:

  • provider layer
  • runtime layer
  • empty tool registry abstractions

Commit 2

Split CoachToolService into built-in tools + registry

Commit 3

Migrate AiCoachViewModel

Commit 4

Migrate RoutineOptimizerViewModel with interrupt/resume

Commit 5

Turn AgentOrchestrator into a temporary façade or remove it if nothing depends on it


12. My strongest repo-specific recommendation

For this repo, I would not try to delete everything at once.

Use this migration rule:

Keep GeminiAiService, ConversationManager, and the current screens.
Add ModelRuntime, DefaultAgentRuntime, and ToolRegistry beside them.
Migrate the VMs first. Then remove old orchestration code.

That gives you the scalable base without destabilizing the product.

If you want, next I can turn this into a literal PR checklist with:

  • file-by-file task list
  • exact constructor changes in main.dart
  • exact methods to extract from CoachToolService
  • exact tests to write for each file

Failed to handle agent chat message. Please try again.

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