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
stepNamecan 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:
- Retrieve context
- Manage conversation history
- Format input
- Validate output
- Combine multiple model responses
- ...
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:
- Unified Model Invocation: Unified invocation through Genkit's provider/plugin system, similar to LangChain's model abstraction. Within a project, you can simultaneously connect a local gemma, LiteRT-LM, or a remote LLM-compatible endpoint protocol via a single
ai.generateStream(...). Switching is just a matter of changing the model resolver. - Structured Output and Type Safety: Genkit emphasizes schemas, similar to using Zod in JS/TS to define
inputSchemaandoutputSchema. The entire Flow not only constrains LLM output but also standardizes the Flow into a stable, verifiable API. This point is actually very important for production systems, because if you've developed an Agent, you'll find that the most feared thing in real business is unstable model JSON output. For example, in a GenKit project, all cross-boundary data structures (TraceReport, ToolResultEnvelope, ClaimExtraction, QueryRewritePlan) carry aschema = "xxx.v1"version number, facilitating the coexistence of future v2 breaking changes. - Tool Calling: Genkit supports process-oriented and structured tool calling. In GenKit, the core is how to securely connect tool calls into the business backend, i.e., how tools flow within the Flow.
- RAG Abstraction: Genkit's official RAG abstraction includes support for Indexer, Embedder, and Retriever, also helping to build RAG flows. For example, my Agent project implemented 5 retriever modes (hashLexical / bm25 / hybrid / embedding / hybridEmbedding) and 384-dimensional n-gram embedding + BM25 + RRF fusion, all built on GenKit's retriever abstraction.
- Development Debugging and Observation: You can add and view a series of traces within the Flow, debug execution links, and see each step of model calls and tool calls. Without this trace, finding bugs in an Agent is basically impossible.
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:
- Capability Snapshot: Host-injected capabilities, such as whether the current model supports tool_call or image input.
- Skill Declaration: A "customer service skill" can declare "only allow knowledge_search + web_search + memory_get".
- Privacy / Approval Policy: Some tools require user confirmation before running.
- Intent Routing: Default returns
modelToolChoice, meaning "the model decides based on the tool schema".
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:
- The first layer filters out some business intents through keywords and regex, using a business rule layer to handle high-frequency, clear, unambiguous requests.
- Then, a small model performs semantic splitting to handle some conventional intents.
- Finally, the large model is responsible for intent integration and acting as a fallback.
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:
directAnswer: No tools needed, direct answer.react: ReAct mode (thought-action alternation), meaning this problem requires reasoning.planAndExecute: Plan first, then execute (complex multi-tool scenarios).
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=trueand 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:
- Main system instruction
- Tool guidance block
- Query rewriting
- Routing plan
- Claim extraction
- Claim verification
- Regeneration after audit failure
- Output quality rewriting
- Tool result synthesis
- Direct answer submission
- Pre-filled tool results
- Missing tool supplementary selection
- Truncation continuation
- Long-term memory line
- History turn line
- General section block
- Truncation marker
- Truncated turn block
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:
- Forced i18n decoupling: The template itself contains no natural language; the locale is injected from outside, avoiding the anti-pattern of "hard-coding Chinese".
- Keeping Dotprompt declarative: The YAML frontmatter declares the schema contract, and the template body is just a "formatting container", avoiding stuffing logic into the template.
- Facilitating GenKit Developer UI usage: Dotprompt can be previewed, edited, and compared with historical versions in the UI.
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:
notInitialized: Process just started, local assets not yet scanned.missingModel / missingRuntime: Whether the model is ready.downloading: Local model binary or tflite weights are downloading.starting / ready: Loading complete, ready to accept requests.running / stopping: Processing a session or shutting down.error: Unrecoverable startup failure.
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,
generateOrToolis an llm actor,executeToolsis an fsm actor, andregenerateFromEvidencereturns 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/processStepsare all append-only fields inside the controller. Each mutation produces a new session object. Only when the state reachescommitTurndoes 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
StateErroris 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'sgenerateStream(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":
- Required tool missing: When the model's output text detects "suspected tool_call but not through the formal channel", such as putting
<execute_tool>inside natural language, it throws_GenkitToolRequiredException. The upper layer catches it and retries recursively withforceToolUse=true, forcing GenKit to use the tool schema withtoolChoice: required. This way, the semantic decision remains with the model. - Context overflow: When catching a model API "context too long" error, trigger
_runCompleteContextOverflowRetries, which retries by incrementally compressing the prompt according tocompactLevel. Each level of compression corresponds to structural actions like "delete history turns / shorten grounding / cut memory". It never deletes the core fields of the current user question or grounding facts. - Truncated answer (finishReason=length): If the generation is truncated by the maximum token count, use
stream_continuation_instructionto let the model continue writing. If there are already tool results, call_synthesizeAnswerFromToolResultsto let the model re-summarize based on the tool results. Both paths hand control back to the model, and the local side only does prompt assembly.
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:
- Reduces the number of internal loop rounds for GenKit maxTurns, saving latency.
- Allows the model to see grounding facts on its first generation, avoiding multiple back-and-forth rounds.
- Side-effect tools (mutating / externalAction) are absolutely never pre-run, strictly adhering to the boundary that "tool side effects can only be triggered by explicit model tool calls".
So, the entire Agent Loop has several principles:
- Looping relies on GenKit, boundaries rely on Dart: For example,
maxTurns=6is handled by GenKit, while Dart only does input preparation, post-boundary checks, and structural error retries.
- Exceptions as control flow: All three recovery paths are completed through "exception → upper layer calls again". The trace can clearly see "how many retries this turn went through".
- Semantic decisions return to the model.
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_searchis a built-in tool, as long as it hasusesNetwork=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:
- Provider Format Normalization: Different LLM providers use different tool-call output formats, such as OpenAI standard JSON,
<execute_tool>...</execute_tool>XML blocks,<|tool_call|>call:prefix formats. These formats require a normalization layer. If any format cannot be normalized, an exception is thrown directly; string keyword fallback cannot be done. If even the tool call cannot be parsed, the upper layer should see an error.
- Parallel Strategy: Only tools with
sideEffectLevel=readOnly && parallelSafe=truecan be called in parallel. Write operations (mutating/externalAction) are forced to be serial to avoid race conditions. High-risk tools (cli,device_control) are forced to be non-parallel and have high-risk warnings, and by default require approval.
- In addition to the three-level recovery, there can be a replan: When a tool execution returns
status: failed, the step can be removed from theExecutionPlan. The failed tool result envelope is kept as evidence (for auditing and subsequent replanning), but the semantics of the execution plan are not regenerated, because the semantics need to be decided by the model in the next round.
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:
- knowledge_search: Static corpus retrieval.
- local_data_summary: Externally dynamically injected real-time data (orders, logs, etc.).
- web_search + fetch_url: Online information.
- memory_get: Long-term memory.
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.75are 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
v1to leave room for future breaking changes (future v2 can coexist).attributesis an open map; each lifecycle step can stuff its own concerned attributes without changing the structure.logssupports event logs, not just span start/end.
Then, on top of Trace, several layers of support can be built:
- Replay Harness: Package all dependencies of a turn (user query, capability snapshot, tool schemas, KB hits, model configuration) so it can be completely replayed offline on another machine. This way, if a user reports "Agent answered wrong", we just need to get the support bundle (the replay package), and the developer can reproduce it on their own machine.
- EvalDataset: Convert annotated traces into dataset items and commit them to git. Any future changes will run dataset regression. When comparing a new trace vs the golden one, the dataset item checks: whether the tool calls sequence/name/parameters match, whether the auditor's judgment is consistent, and whether the answer is semantically equivalent (optional LLM judge).
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:
- Key-based: If a "structured sensitive field" name is hit (e.g., apiKey / authorization / bearer / token / secret / password / cookie), the value is directly masked as
[secret]. - Text-based: Regardless of whether the key is sensitive, run a chain of 6 serial replace operations on any string content to catch implicit keys within strings (
api_key=xxx/sk-xxxx/ghp_xxxx/Authorization:/ long base64 / local paths / stack traces, etc.). - Length Limit: A single string defaults to a maximum of 1200 characters to prevent accidental leakage of large blobs.
Besides information masking, there are two other types of "runtime security":
- Cancellation: CancellationToken runs throughout. Mainly, when the user clicks "stop", all in-flight tool calls, model calls, and retriever queries will immediately throw a
CancelledException, avoiding the situation where "the user has given up but the Agent is still secretly running". - Context Overflow Guard: When detecting a model API returning "context too long", trigger a budget tightening retry instead of failing directly.
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:
- Let Model A (answer model) generate an answer first.
- Let Model B (possibly another call of the same model) extract every factual claim from the answer and submit structured results via a tool call.
- Let Model B locate each claim within the evidence and submit a verdict via another tool call.
- If any
verdict != grounded, it producesclaimGroundingFailed.
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:
- Engine catches the audit issue.
- Uses
audit_failure_regeneration_instructionto assemble a recovery prompt, containing specific issue code + evidence.
- Lets the model regenerate based on the feedback.
- Re-runs the audit.
- Then, for example, up to 3 times. If all fail, it's a hard fail (returning audit issues structured to the upper layer).
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:
- Shared Runtime + Registry: The entire process shares one
Genkitinstance. All Flows, Tools, and Prompts are registered in the same registry. The benefit is that the replay harness and the real runtime use the same registry, avoiding drift like "tool not registered during replay".
- Every Tool is a GenKit Tool: All 11 built-in tools are registered as real
genkit.Tools viaai.tool(), with completeJSON Schema (input)+description (i18n).generateStream(tools: [...])directly passes these tools to the model. The model uses the standard tool_calls channel, and GenKit internally completes the "call → execute → feedback" loop. The only thing the Dart side needs to do is provide the tool executor function.
- Every Prompt is a Dotprompt: All prompts use the Dotprompt format, loaded via
ai.prompt('local_agent/system_instruction'). That is, prompts are versioned assets. You can change the copy without touching the code, while retaining regressability (the asset will be packaged into the support bundle for replay use). At the same time, prompts have YAML frontmatter, allowing schema, model, and config to be declared, enabling direct debugging in the GenKit Developer UI.
- Every Turn is a GenKit Flow: Each turn is registered as
agent_turn_flow. This makes Trace naturally visualizable (opening the Developer UI shows the 21 lifecycle step timeline), Eval naturally runnable (GenKit eval harness can directly run datasets against the Flow), and Replay naturally feasible (Flow is a pure function; as long as the input is the same + model responses are frozen, the output is the same).
- Lifecycle steps are all real spans: Everything that GenKit can host is made into a real GenKit step/tool/span. This way, when a problem occurs, the trace can precisely point to "which lifecycle step failed".
Then, some design principles are also important, as mentioned earlier:
- Schema as Contract: From tool
inputJsonSchemato TraceReport'sschema="local_agent.trace_v1", every "cross-boundary data structure" in the entire system has a schema name. This way, Trace can be replayed (old data won't be misread if the schema changes), Tools can do strong validation (model output tool_call parameters must conform to the schema), and Prompts can be reused (the same schema can be fed to different models).
- Model as Router: IntentRouter is neutral and does not do keyword matching. Query rewrite generates 5 types of variants through the model. Tool selection is decided by the model based on the schema. Audit conclusions are judged by the model based on evidence. Long-term memory writing is actively done by the model via memory_write. Our local code only provides capabilities, schemas, permissions, and privacy boundaries; all semantic decisions are handed over to the model.
- Auditor only verifies, does not generate: The Auditor has the ability to reject, trigger regeneration, and return structured issues, but cannot write answers for the model.
- All observations are replayable first-class citizens: TraceReport needs to be serializable. The Support bundle packages all dependencies. The Replay Harness can reproduce offline. EvalDataset can be periodically regressed.
- i18n: Dotprompt does not hard-code natural language. All labels/descriptions go through the messages interface. The locale is injected from outside. Even the
<truncated>marker is rendered according to the locale.
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:
- Because the code repository itself is a structured, searchable, executable, and verifiable context environment. It can directly grep files, read code, run tests, see errors, and then locate problems step by step.
- But enterprise business Agents face private knowledge and real-time business facts scattered across document libraries, databases, CRMs, work orders, and permission systems. In this case, RAG is more valuable. Having a retrieval-augmented layer that fetches the correct data under the correct permissions and then hands it to the model for judgment. Various images, PPTs, PDFs, and Word documents are split into multiple layers, planning tags, summaries, original texts, and attachments into a knowledge base system.
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.
Top 2 of 3 from juejin.cn, machine-translated. The original thread is authoritative.
I've learned nothing.
so easy
Totally baffled [shocked]