跪拜 Guibai
← Back to the summary

Building a Production Flutter Agent with GenKit: State Machines, Tool Safety, and Audit Loops

I've posted a few articles about Agents before, and some people asked if there were any engineering practice examples. This time, let's talk about practical implementation through a Flutter Agent implementation.

The full text is still quite long.

When developing Agents in Flutter, you can hardly avoid GenKit, because Genkit-Dart is Google's official Agent SDK for Flutter, and it's almost the only choice. GenKit's overall capabilities are relatively close to LangChain, with the core being to help application development string together models, Prompts, tools, RAG, structured output, observation, evaluation, and deployment. Of course, its capabilities are definitely weaker compared to LangChain's advanced LangGraph suite.

However, Genkit's core capability is not Chains; in Genkit, everything revolves around Flows. The positioning of a Flow is mainly:

Wrapping a piece of AI business logic into a function that can be type-checked, debugged, traced, deployed, and evaluated.

It looks something like this. This is the data flow diagram for a single turn in my own GenKit Agent project, which is also the content we'll discuss today:

For example, in my case, the entire Agent project defines 21 lifecycle step orchestration sequences within this set of Flows. At the same time, the corresponding stepName can be seen in the trace, and each user submission request undergoes state transitions within the entire Flow.

This is because an AI workflow in an Agent scenario is not just a single model call. A normal Agent today generally needs to:

These are just the basics, not even including various details and engineering management. GenKit's Flow provides engineering capabilities for type-safe input/output, streaming output, and Developer UI debugging.

More specifically, Genkit's key capabilities can be divided into several layers:

GenKit's core role lies in providing such a set of standardized engineering capabilities, integrating model invocation, business data retrieval, structured output, tool calling, RAG retrieval, logging/tracing, debugging, and deployment into a single SDK.

So GenKit can also be seen as a protocol-based orchestration system for Agents. Its positioning is similar to LangChain, but not as heavy, and it lacks the rich state management and coordination capabilities of LangGraph. Therefore, many things still need to be implemented by ourselves, such as state machines and human-on-the-loop, which require separate implementation.

Returning to the practical project, the following table shows the structure of a simple Flutter Agent I implemented using GenKit. It also represents the capability scenarios an enterprise-grade ordinary Agent needs to possess. That is, even with the GenKit SDK, what you need to do is not just calling APIs; it's more about how to design a complete Agent Flow transition mechanism on top of GenKit:

Group Features and Technical Points
Agent Core Agent Loop
Trace Wrapper + TurnExecutor
11-state FSM + GenKit Flow
Controlled Session Mutability
Turn Plan (Intent + Tool Set)
ReAct / Plan-Execute Planner
Tool 11 Built-in Tool Declarations
Execution, Approval, Timeout, Retry
Built-in handler + MCP client
Host-injected tools
Tool Routing Projection
Normalization of three provider private formats
MCP whitelist policy result schema
Remote topic forwarding
RAG BM25 / Embedding / ANN / Reranker
Knowledge Base Orchestration
Three types of material → chunk
5 types of query rewrite protocols
History Summary
Data Fact Context
Data Message Persistence
DDG / SearxNG / Open-Meteo
Prompt Segmented Assembly + Budget Trimming
Capability/Permission Capability Snapshot
Skill Declaration
Intent Routing (Neutral)
Audit Grounding Audit
Claim Extraction and Verification
Observability Trace Data Structure
Eval dataset
Dynamic Regression
Version Comparison
Observer Interface
Storage Session Main Database
Long-term Memory
Attachment Binary
Security Data Masking
Cancellation Token
Context Overflow Retry
Model Chat Message & Model Definition
OpenAI Compatible Client
Local Inference Runtime
Multimodal content part
Token Estimation
GenKit Resources GenKit runtime/tool registration

The above features, when expressed in engineering, would roughly result in the following project structure and capability dependencies. If the image isn't compressed too small, it should be clear:

From this diagram, it can be seen: All semantic decision paths ultimately need to converge on the GenKit runtime, while the Dart-side modules only stuff schemas, tools, prompts, and evidence into it, and then let the model make decisions based on these "structured inputs". All business logic needs to flow within the Flow.

Plan

So, in engineering, a usable enterprise-level Agent requires considering many points. For example, the Turn Plan. You can't just directly send the user's words to the large model for execution. You need to break down the user's natural language into real intent, determine how many steps are needed for execution, which tools need to be called, and what the causal relationships are:

class AgentTurnPlan {
  final String intent;                       // Intent (determined by model/routing plan)
  final Set<String> requiredToolNames;       // Tools that must be used in this turn
  final Set<String> forbiddenToolNames;      // Tools forbidden in this turn (privacy/permissions)
  final Set<String> candidateToolNames;      // Candidate tools for this turn
  final List<String> activeSkillIds;         // Activated skills
  final LocalAiModelCapabilities modelCapabilities;
  // ...
}

From the model's final input perspective, it sees: "You must call at least one of these tools", "You cannot use these tools", "These are available for use". The model doesn't need to guess whether permissions or privacy allow it.

For example, in my Agent implementation, *TurnPlan uses a "three-set pattern" to express tool boundaries (required / forbidden / candidate). The model doesn't need to guess at the natural language level "Can I not call device_control in this scenario?", because it simply cannot see the device_control tool*. What the model actually sees is:

Moreover, this 'intent recognition' is not just about letting the large model split things up. A more professional approach requires a layered design, for example:

And each layer also needs confidence judgment, degradation strategies, and clarification mechanisms, etc.

On top of TurnPlan, an ExecutionPlan layer is generally expanded, decomposing TurnPlan into a sequence of ExecutionSteps. Each step carries goal / toolName / required / readOnly / parallelSafe / reason. At this point, there are three overall strategies:

However, ExecutionPlan is not a "hard-coded script". It is a hint for the model, mainly written into the prompt. During GenKit generation, the model can completely deviate from this plan (e.g., temporarily deciding not to use a tool), and the code does not enforce it.

The real "must call" is guaranteed through forceToolUse=true and the post-hoc _ensureRequiredToolsWereUsed() check. This combination of "soft plan + hard invariant" achieves a compromise that respects the model's reasoning freedom while ensuring engineering constraints are not broken.

Prompts Templates

Next are the prompts templates in the previous flow. Here, different Dotprompt templates are integrated into the Flow process, mainly providing these scenarios:

All these prompts use GenKit Dotprompt (YAML frontmatter + template body). At runtime, locale/context variables are injected through the AiPromptMessages interface. This avoids hard-coding a specific language in the prompt and allows prompts to be assembled based on state:

Therefore, prompt template management is also a very important module in an Agent project. For example, if a certain locale gets disordered or lost during the flow, it might lead to the wrong language being selected for the prompt template, and then the conversation suddenly outputs in another language.

In terms of Dotprompt writing, the vast majority of template bodies only have a minimalist injection like {{systemInstruction}}. The actual string is generated on the Dart side by PromptMessages.systemInstruction() and then passed in. The main reasons for this are:

A detail here is 'dual-budget trimming', because characters and tokens are not linearly related. For example, a single CJK character might occupy 2-3 tokens, while an English word is one token. So, the prompt builder needs two levels of processing: first, run a character budget for cheap coarse filtering (without running the tokenizer), then run a token budget for final review (must run the tokenizer). The fallback priority during trimming is also fixed:

conversation_turns → memory → web → knowledge → data → (user_turn is never trimmed)

That is, the current user's user_turn is always kept intact. This is the key boundary of "user intent must not be distorted":

You can cut history, you can cut long-term memory, you can cut web search results, but you absolutely cannot cut the user's sentence itself.

State Machine

Next is the state machine, which is further divided into coarse-grained and fine-grained states. For example, in my Agent project, I defined a 9-state coarse-grained state machine, mainly used to handle:

This 9-state state machine concerns itself with "whether the process is usable". For example, if a manifest exists but the corresponding binary model is missing, a download must be triggered; whether the remote model's Provider is configured; a health check must pass before entering running.

Then there is the state machine for each business Flow, with 11 states divided into 3 normal phases and 2 error/interruption branches. At the same time, composite state is used to mark the mapping relationships of the 4 terminal states. Most importantly, each transition boundary is annotated with the trigger source (which method/exception), so that when problems occur, they can be traced back through the trace:

In the 11 states, each state has a clear Actor tag (fsm / llm / genkit). This tag is extremely critical:

It explicitly tells the user in the trace "whether the failure of this step is a model problem or an engineering problem", avoiding unclear responsibility during regression. For example, generateOrTool is an llm actor, executeTools is an fsm actor, and regenerateFromEvidence returns to the llm actor.

Because an Agent is a naturally polymorphic object. For example: the answer grows as it runs, thinking does too, and tool results can join the Flow at any time. If these "mutable" buffers are not constrained, data races can easily occur between the UI layer and the persistence layer, such as the UI getting a half-finished answer and outputting it directly.

Therefore, each Turn needs independent task management. Only when the state reaches commitTurn or interruptedCommit is the final session reply outputted at once through a unified committer, preventing output disorder.

For example, the specific approach in my project is 'immutable session objects paired with replace assistantTurn'. That is, answerBuffer / thinkingBuffer / processSteps are all append-only fields inside the controller. Each mutation produces a new session object. Only when the state reaches commitTurn does the committer output the final session at once.

Another very important constraint is the invariant check of terminal states. For example, the four terminal states (committed / interruptedCommitted / stopped / failed) have strict invariants:

switch (output.terminalOutcome) {
  case committed:            // Must !errorPresent && state==commitTurn
  case interruptedCommitted: // Must committed && errorPresent
  case stopped:              // Must !committed && stopped
  case failed:               // Must !committed
}

If any invariant is violated, a StateError is thrown directly. It's better to let the upper layer see an obvious crash than to have silent pollution like "it looks committed but actually has an error".

Agent Loop

Next is the Agent Loop. It undertakes the complete process from "receiving a request to spitting out a response". The core is to execute all the plans from the Planner. In GenKit, this means:

The "tool loop" is completed internally by GenKit's generateStream(maxTurns:).

Here is a key choice that is completely different from many implementations that "write their own ReAct loop":

The Dart side does not write a while (needsTool) loop. The tool loop is completed internally by GenKit's generateStream(maxTurns: x).

This is also the core of using Genkit. You need to let the Loop flow within GenKit. Never re-implement locally what GenKit can do, because writing your own while loop means you have to re-handle tool_call parsing, tool_result backfilling, maxTurns limits, cancellation, streaming chunking, etc. This essentially defeats the purpose of using GenKit.

Additionally, the LLM will definitely make execution errors. So, in my project, I mainly use three recovery paths to solve these three types of problems. All recoveries complete the link loop through "throw exception → upper layer calls _streamWithGenkitTools again":

Another interesting point here is the Prefill mechanism:

Before the Agent Loop starts, required tools that can already be determined (like knowledge_search, current_time, these side-effect-free tools) are pre-run, and the results are stuffed into the prompt as "completed tool_calls".

The benefits of doing this are:

So, the entire Agent Loop has several principles:

Tools

Then comes the tool layer. The core is to define tool descriptions well, then manage data flow and trust levels. Define which tools have risks, which tools can be executed, and what the business capabilities of the tools are. From tool injection, tool execution, to the flow of tool results, it must be traceable and secure:

Specific to the project, for example, my project has 11 built-in declared tools, such as local data summary, knowledge base retrieval, current time, web search, URL fetching, CLI commands, MCP, device control, long-term memory read/write/forget, etc. For each tool, it's not just about simply writing a 'name and description'; we need to define a complete set of capability tags:

Dimension Field Semantics
Schema inputJsonSchema + validateInput() Strict additionalProperties:false
Side Effect sideEffectLevel readOnly/idempotent/mutating/externalAction
Parallel parallelSafe + canRunInParallel Only readOnly + parallelSafe are parallelized
Risk riskLevel low/medium/high
Privacy resultMayContainPrivateData Determines if it can be fed back to remote model
Network usesNetwork Determines trust level downgrade
Budget resultCharBudget + trimResultContent Result character budget

These tags are the runtime decision basis. ToolBroker reads these fields before execution to decide:

Whether it can be initiated in parallel, whether the result needs to be desensitized before being passed back to the model, whether it needs to go through an approval flow, and whether a result exceeding the budget should be truncated or errored out. This is what is often called "schema = contract" in Agents. All semantic decisions are based on declarative metadata; local code does not need hard-coded branches.

A Trust × Side Effect matrix can also be overlaid:

Trust \ SideEffect readOnly idempotent mutating externalAction
localTrusted ✅ Auto ✅ Auto ⚠️ Approval ❌ Deny
hostTrusted ✅ Auto ✅ Auto ⚠️ Approval ⚠️ Approval
externalUntrusted ✅ Auto ⚠️ Approval ❌ Deny ❌ Deny

Trust level is also very important. For example, if I automatically downgrade online built-in tools to externalUntrusted, then even if web_search is a built-in tool, as long as it has usesNetwork=true, the corresponding result needs to be considered "externally untrusted" and must be desensitized before being returned to the model.

The entire tool execution flow in GenKit is similar to the following. From the model's tool_call to the landing tool result, full-process management is required:

There are actually several points here worth expanding on:

RAG

RAG is also a very important part. It determines whether answers with factual basis can be output. For example, the RAG in my project has 4 grounding pathways + 5 retrieval modes + RRF fusion:

These four pathways are completely decoupled, each maintaining its own provider interface. The Host can replace any path according to business needs without affecting the others.

For example, a customer service scenario might close the online pathway (privacy-sensitive) but open the DataContext pathway to feed in the order table in real-time. The Retriever itself is polymorphic, with 5 modes:

Mode Scoring Method Scenario
hashLexical Cosine of 64-dim hash embedding Small corpus, fast fallback
bm25 Classic BM25 (k1=1.2, b=0.75) Keyword matching focused
hybrid hashLexical + normalized BM25 sum Default steady state
embedding Cosine of 384-dim n-gram embedding Semantic matching
hybridEmbedding embedding + normalized lexical Highest quality, slowest

The BM25 constants k1=1.2, b=0.75 are empirical defaults in the information retrieval field, very suitable for short document corpora. However, BM25 is naturally unfriendly to Chinese; space splitting is completely ineffective for Chinese. So, for Chinese, you can compensate by generating tokens for all unigrams/bigrams/trigrams. Actually, it depends on your specific scenario and needs.

Furthermore, one layer up is Query Rewrite. For example, I made 5 types of query variants:

Kind Semantics Example (Original query: "How to detect large file read/write lag")
originalNaturalLanguage Original query How to detect large file read/write lag
keywordTerms Keyword-ized large file read write lag detection
technicalTerms Technical terms file I/O latency detection benchmark
codeApiConfig Code/API/Config names Dart:File.readAsBytes / iostat / fsync
synonymExpression Synonym expansion Check disk write latency Observe IO blocking

The generation method is to let the model output normalized JSON, validated locally against the schema. No synonym table is hard-coded here; the model dynamically generates based on the current query. This is another manifestation of "model = router". After querying with the 5 types of variants separately, they are fused using RRF (Reciprocal Rank Fusion):

for each variant q:
    hits[q] = retriever.search(q, limit*2)
    for hit at rank r in hits[q]:
        mergedScore[hit.chunkId] += hit.score + 1/(r+1)
sort by mergedScore

Pure RRF (which only uses 1/(rank+k)) is not used here. What's used is a mix of the original score + the reciprocal of the rank, to avoid completely losing the original semantic similarity information.

The combination of Query Rewrite and RRF is the foundational quality capability of the project's RAG, because a single-query BM25 is helpless against "term mismatch" (user says "lag", document says "latency"). The 5 types of variants can basically enumerate most cases of retrieval failure.

After fusion, there is also a Source Diversity Reranker: Each sourceId contributes at most K chunks, avoiding hits concentrated in a single document. This reranker does not change the score sorting of individual chunks, only ensuring result diversity.

This is very critical for grounding: the model seeing 3 similar chunks is not conducive to reasoning, but seeing 3 chunks from different angles can form an effective context.

Trace

Then there is the trace. It is a very important standard and replay support when an Agent encounters problems. Through the trace, you can know "what the Agent did and why it did it", turning the entire link into a fully auditable process. For example, my entire trace stack here has 5 layers, with the perspective being a "pipeline from data production to consumption":

For example, in GenKit Flow, every ai.run(name, fn) automatically generates a span, so TurnFlow defines 21 span names.

Specific to the TraceReport structure:

class AgentTraceReport {
  final String schema;                 // "local_agent.trace_v1"
  final String turnId;
  final String sessionId;
  final DateTime startedAt;
  final DateTime finishedAt;
  final List<AgentTraceSpan> spans;    // 21 lifecycle steps
  final AgentTraceModelCall? modelCall;
  final List<AgentTraceToolCall> toolCalls;
  final AgentTraceAuditor? auditor;
  final Map<String, dynamic> metrics;
}

The schema name carries v1 to leave room for future breaking changes (future v2 can coexist). attributes is an open map; each lifecycle step can stuff its own concerned attributes without changing the structure. logs supports event logs, not just span start/end.

Then, on top of Trace, several layers of support can be built:

Through DynamicRegression combined with ReleaseDashboard, periodically run EvalDataset, output pass rate trends, group pass rates for each tag, slowest span rankings, a list of degraded cases, and trace diffs. This is how continuous maintenance can keep moving forward.

Data Masking

This is a follow-up to the Trace above, because Trace is a double-edged sword. It records all details, which means it could leak keys, paths, and personal information. Therefore, a unified data masking mechanism is necessary to prevent sensitive information from being carried out in scenarios like support bundles, trace persistence, and tool result feedback to the model.

For example, my project uses a three-layer strategy:

Besides information masking, there are two other types of "runtime security":

Auditor

Finally, there is the Auditor review. Because during execution, the LLM can make mistakes, make wrong calls, and output wrong results. Therefore, corresponding factual auditing is needed:

The Auditor has an extremely important responsibility boundary:

The Auditor can only verify and reject; it cannot generate domain answers.

The Auditor absolutely cannot and will not rewrite the model's answer. It can only judge pass/fail and trigger model regeneration. For example:

The model says "The order amount is 100", but the evidence shows "The order amount is 120". Then you can only report an error, you cannot directly modify it.

This boundary seems "inefficient", but adhering to it maintains the system's traceability, because all answers are generated by the model, and local code does not participate in semantic decisions. This way, the answer in the trace 100% reflects the model's capability, making evaluation results more credible.

Additionally, audit conclusions are best expressed using structured enumerations (IssueCode):

Code Semantics
emptyAnswer Answer is empty after desensitization/denoising
missingFactReference Fact binding is required but missing
missingGroundingFact A trusted current-tool fact exists but the answer does not reference it
exactValueMismatch Numerical/exact value does not match the tool's return
numericOutOfRange Numerical value exceeds the allowed range of the fact
structurallyIncomplete Answer is structurally incomplete (e.g., broken JSON)
claimGroundingFailed A claim extracted by the model cannot be located in the evidence

Upper-level decisions only look at code / severity / factKind, not the "rendered issue text". The text is only for display to the user.

The real core inside the Auditor is mainly Claim Extraction & Grounding:

Doing this cannot guarantee 100% hallucination-free, but it can significantly reduce the probability of "facts appearing in the answer that were never mentioned in the evidence".

Then, after the Auditor judges a fail, it can enter the Regenerate loop:

Regenerate is based on structured feedback, not a simple prompt instruction like "let the model try again". The feedback explicitly tells the model "Your claim X cannot be found in the evidence, please correct or delete it", giving the model a clear direction for improvement.

GenKit Engineering Summary

At this point, we've covered the major modules that need to be understood in Genkit's engineering. However, more details weren't discussed here because the length is already very long. Looking back, the reason the previous modules can be implemented is largely due to GenKit's role as the "engineering territory" in the project. For example, my project's use of GenKit has a few deliberate points:

Then, some design principles are also important, as mentioned earlier:

Finally

So, this is what a very ordinary, common engineering scenario for implementing a localized Agent based on GenKit looks like. In fact, this is already a very basic enterprise Agent implementation scene.

The advantage of using GenKit is that it supports many languages. At the same time, GenKit Dart can be used on both the client and server sides, and can even interact with GenKit Python/TS. Using GenKit in Flutter also allows for full-platform App scenarios. So, to some extent, this is also a reason why Flutter has grown a bit more in the Agent era.

In fact, some people also ask, if there's OpenCode, why write your own Agent?

Because the scenarios are different. The greatest significance of an in-enterprise Agent is your special business Flow, your special transition processes. Your intent boundaries are not universal, your tool set is not universal, and your privacy policies are even less universal. You cannot use a general-purpose product to stuff these "boundaries" in, or if you do, it becomes a pile of switches, rules, and if/else, which is actually harder to maintain.

Some also ask why RAG is needed. For example, why doesn't Claude Code use RAG? And then we keep emphasizing the importance of RAG when doing Agent business? This is where the scenario distinction lies:

So, the scenarios are different. The core of Claude Code is "searching and verifying within a code environment", while the core of an enterprise Agent is "connecting to enterprise real knowledge and business systems". Therefore, enterprise Agents need RAG. But this RAG is not just a vector database; it also corresponds to a generalized external knowledge system composed of document retrieval, SQL/API, permission filtering, rule validation, and evidence citation.

The value provided by the Agent SDK is precisely here. GenKit is not as strong in state orchestration as LangGraph, nor as miscellaneous as LangChain. It just abstracts the core pieces of Flow / Tool / Prompt / Trace with a set of specifications. What you need to do is build your own FSM, Planner, Auditor, RAG pathways, Skill system, and privacy boundaries around it.

So, the role of an Agent has never been to make the model smarter. What we need to do is constrain the LLM's capabilities within our business scope, while being able to trace back and verify. This is the meaning of building an independent Agent.

Comments

Top 2 of 3 from juejin.cn, machine-translated. The original thread is authoritative.

AntG

I've learned nothing.

恋猫de小郭

so easy

millionCC

Totally baffled [shocked]