SunriseAI.Api — Agentic Loop Study Notes¶
Where it lives¶
AgentService.RunWithAnthropicSdkAsync — the active path (AiSettings.ActiveProvider
== "claude"). There's a whole other Semantic Kernel path in RunAsync's main body for
OpenAI/Gemini, but per CLAUDE.md it's legacy/alternate, not what production traffic hits.
Manual while loop against the native Anthropic SDK, not SK's auto-function-invocation.
The loop, shape¶
build system prompt (cached) + message history + user message
↓
call Claude, tools=[execute_sql]
↓
while response.StopReason == "tool_use":
for each ToolUseContent block in response:
run SQL (or reuse from sqlResultCache)
append ToolResultContent
call Claude again with tool results appended
↓
StopReason != "tool_use" → extract JSON → parse into AgentResponse
Single-tool design: the model has exactly one tool, execute_sql. No search, no
multi-tool routing — the entire "agentic" surface is "keep calling SQL until you have
enough to answer."
Things worth being able to explain in detail¶
Prompt caching — why the system prompt is built the way it is¶
SystemMessage gets CacheControl { Type = ephemeral }, and MessageParameters.
PromptCaching = PromptCacheType.FineGrained. Caching on Anthropic's side is a byte-
for-byte prefix match — anything that changes the system prompt text between requests
(even one character) invalidates the cache and you pay full price again.
Two concrete consequences of that constraint, both deliberately engineered around:
- SystemPromptBuilder always injects the FULL schema, never conditionally based on
the user's question. Tempting "optimization" (only send schema for tables the question
needs) would make the prompt variable per-request, breaking the cache — CLAUDE.md
explicitly calls this out as a rejected optimization: ~5x more expensive with
selective injection + no cache, versus full injection + cache hit.
- {{TODAYS_DATE}} is injected as yyyy-MM-dd, not a full timestamp. A full
timestamp changes every second, which would invalidate the cache on literally every
request. Date-only means the cached prefix only turns over once per calendar day —
Anthropic's own caching guidance recommends exactly this: keep volatile content
coarse-grained rather than leaving it out or making it maximally precise.
Token accounting reflects this split explicitly: InputTokens (new/uncached),
CacheReadTokens (served from cache, ~10% of normal cost), CacheCreationTokens
(this call paid to populate the cache). TotalEffectiveInput = InputTokens +
CacheReadTokens is logged as the real "how much context did this call actually see"
number, separate from what got billed at full price.
sqlResultCache — per-turn, not cross-session¶
A Dictionary<string,string> keyed on the trimmed, exact SQL string, scoped to one
call of RunWithAnthropicSdkAsync (i.e. one user turn, across however many tool-use
rounds happen within it). If Claude issues the same SQL twice in one turn — self-
correction retrying an identical query, or a follow-up sub-question that happens to
repeat an earlier query — the second hit skips DuckDB entirely and reuses the cached
CSV text. No staleness risk since it's rebuilt fresh every turn; this is purely about
not paying DuckDB execution cost (or, more importantly, not re-sending an identical
large result block into a subsequent API call) twice in the same conversation turn.
Why CSV, not JSON, for tool results¶
SunriseSqlPlugin.SerialiseResult returns CSV, explicitly called out as "significantly
more token-efficient than JSON for tabular data" — no repeated {"col": ...} key names
per row, just one header row. Every tool result gets re-sent as input on every
subsequent call in the same loop (that's how the SDK conversation model works — full
message history goes with every request), so this compounds: a verbose serialization
format costs you once per remaining round-trip in that turn, not once total.
The 25-row summarization threshold¶
Above SummaryThreshold = 25 rows, the plugin doesn't send the full result — it sends
the first 25 rows as CSV plus a computed numeric summary (total, min, max,
average, count) for every column where more than half the values parse as numeric,
skipping columns ending in _id/_pk/_code (their sums are meaningless). This lets
Claude answer "what's the total revenue" for a 10,000-row result without the full
10,000 rows ever entering context — the aggregate math is precomputed in C#, not left
for the model to eyeball from truncated data. Good design instinct: don't trust an LLM
to "add up" a truncated column correctly, compute the real aggregate server-side and
hand it over as a fact.
max_tokens truncation — the one un-recoverable failure mode¶
If StopReason == "max_tokens", the response is caught before attempting JSON
extraction at all — a truncated response can't produce valid JSON no matter how the
extraction logic is written, so this is special-cased into a canned user-facing message
("got cut off, ask something more specific") rather than falling through to the normal
parse-then-degrade path.
ExtractJsonPayload — read this section, it corrects something CLAUDE.md gets wrong¶
CLAUDE.md (as of whenever it was last edited) says: "leading prose before { is not
stripped (by design: chart tokens {{chart:N}} contain braces, so blind brace-scanning
from the first { anywhere in the text is unsafe and was deliberately rejected)" —
implying a "Perfect! Now let me..." preamble before real JSON is unrecoverable by code,
prompt-only fix required.
That's stale relative to the actual current code. ExtractJsonPayload (AgentService.cs
~L555) does NOT anchor on the first { — it anchors on the literal pattern
\{\s*"answerText" via regex, searched anywhere in the text, not just position 0.
Once found, it does the escape/string-aware brace-depth walk from that point to the true
matching }. Since {{chart:N}} never has "answerText" immediately following an
opening brace, anchoring on the key specifically — not on any { — sidesteps exactly
the ambiguity CLAUDE.md describes as the reason mid-text scanning was rejected.
Net effect: a "Perfect, now let me summarise the results:\n{\"answerText\": ...}" response IS now correctly recovered by code — both the leading prose and any trailing prose get logged as warnings (so recurring phrasings can still be harvested into new few-shot examples) and stripped, and the clean JSON is what actually gets parsed.
There's also a fenced-code-block path checked first (```json ... ```, found
anywhere via regex, not just at position 0) — same prose-logged-then-stripped treatment.
Practical implication: the current code handles more failure shapes than CLAUDE.md
credits it for. If asked "can code fix a leading-prose failure," the honest current
answer is "only if the JSON itself starts with {"answerText" somewhere findable in the
text" — which covers the actual observed "Perfect! Now let me..." pattern CLAUDE.md
cites as its motivating example. The one shape that would still defeat this: prose that
never contains the literal "answerText" key at all (e.g. the model abandons JSON
entirely and just writes an answer in prose) — that's caught by branch 3 (nothing
recognisable found) and falls through to the raw-text-wrap degradation, same as always.
Worth flagging to whoever maintains CLAUDE.md that this section needs a re-read.
Graceful degradation, the actual philosophy¶
Never throws on a malformed response. Chain of fallbacks:
1. Try direct JSON parse of the raw response.
2. If that fails, run it through ExtractJsonPayload (fence-strip / key-anchor-strip)
and try again.
3. If still not valid JSON, wrap the (cleaned) raw text as AnswerText with empty
Charts — "ugly but visible" beats a hard failure the user sees as a broken app.
4. The only case that skips straight to a canned message instead of attempting any of
the above is max_tokens truncation, because no extraction can recover from that.
Who manages the growing message list within one request — NOT Semantic Kernel¶
For the Claude path, the answer is: a single, plain C# List<Message> local variable
inside RunWithAnthropicSdkAsync, built and mutated by hand. No framework owns it — the
loop appends to it directly with messages.Add(...) at each step. Nothing manages this
"for" the code; the code manages it explicitly, in-process, for the lifetime of that one
method call.
The shape of messages as it grows across one HTTP request, concretely:
-
Before the first API call (
AgentService.cs~L252-271):messages = [ ...stripped prior turns from MariaDB (User/Assistant, text only)..., new Message(User, userMessage) ]The system prompt is NOT part ofmessages— Anthropic's Messages API models it as a separate top-levelSystemfield onMessageParameters(parameters.System = systemMessages), set once, sent unchanged on every call in the loop (this is exactly what makes the ephemeral cache hit repeatedly within one turn, not just across turns). -
First call:
SendWithRetryAsync(parameters)sendsSystem+Messagesas-is. -
If
response.StopReason == "tool_use"(loop body,AgentService.cs~L334-404):messages.Add( Message(Assistant, response.Content) ) // the model's tool_use block(s) // ...execute each requested SQL, build ToolResultContent per tool_use_id... messages.Add( Message(User, toolResultContents) ) // tool results, modeled as a "user" message parameters.Messages = messages // same list reference, reassignment is a no-op but harmless response = await SendWithRetryAsync(parameters) // next call, full history so farAnthropic's API convention: tool results are sent back as aUser-role message containingToolResultContentblocks keyed byToolUseId— not a dedicated "tool" role the way OpenAI's API shapes it. -
Loop repeats for as many tool-use rounds as the model needs — each iteration appends one Assistant (tool_use) message and one User (tool_result) message to the same growing list, and every subsequent call resends the entire accumulated list plus the unchanged cached system prompt.
-
Loop exits once
StopReason != "tool_use"— the final Assistant text response is what gets extracted (ExtractJsonPayload) and parsed intoAgentResponse.
Where this state lives, and where it doesn't: messages is a method-local variable.
It exists only in memory for the duration of that one RunWithAnthropicSdkAsync call —
one HTTP request, one user message, however many tool rounds it took. Nothing about
the tool-calling trace (which SQL ran, what came back) is ever persisted anywhere.
Once the method returns, that list and everything in it is garbage. What actually
survives to the next message is only what ChatRequestHandler saves afterward — the
final AnswerText + Charts, stripped to plain role+text (see 05-persistence-layer.md)
— which is what gets loaded back as "prior history" for the next user message, which
then starts an entirely fresh, empty messages list of its own. The model has zero
memory of what SQL it ran in a previous turn — only of what it, in the end, said.
Contrast with the SK path (Gemini/OpenAI/Groq): SK's own ChatHistory object plays
the equivalent role, but the growth/mutation happens inside SK's
GetChatMessageContentAsync call via FunctionChoiceBehavior.Auto() — invisible to
AgentService, which only sees the single call it made and the final result. This is
exactly why ToolRounds is hardcoded to 0 on that path (noted earlier in this file) —
there's no equivalent to loopIteration to count, because the loop isn't hand-written
there, it's internal to SK.
Quick recall drill¶
Q: Why does the system prompt inject the entire schema on every request instead of only what's relevant to the question? A: Prompt caching is a byte-for-byte prefix match. Any per-question variability in the system prompt breaks the cache and makes every request pay full input-token price instead of ~10% for cached tokens — full injection + cache hit is cheaper than selective injection + cache miss, even though it "wastes" tokens on unused schema.
Q: Why is today's date injected as just yyyy-MM-dd instead of a full timestamp?
A: Same cache-prefix-match constraint — a timestamp changes every second and would
invalidate the cache on every single request. Date-only means the cache only turns over
once a day.
Q: What happens if Claude issues the exact same SQL query twice in one turn?
A: Second call is served from sqlResultCache (an in-memory dict scoped to that one
turn) — no second DuckDB round trip, and the cached CSV text is reused rather than
re-executing.
Q: Why CSV instead of JSON for tool results? A: Token efficiency — no repeated key names per row — and it compounds because every tool result gets resent as context on every subsequent call within the same loop.
Q: How does the plugin avoid dumping 10,000 rows into context for a big aggregate query? A: Above 25 rows it sends only the first 25 as CSV, plus a server-computed numeric summary (total/min/max/avg/count) per numeric-looking column, so aggregate answers don't depend on the model eyeballing a truncated sample.
Q: Can a "Perfect! Now let me look at that..." preamble before real JSON be recovered
without a prompt change?
A: Yes, as of the current ExtractJsonPayload implementation — it searches the whole
response text for the {"answerText" key pattern (not just position 0) and extracts
from there via a brace-depth walk, logging the stripped preamble as a warning. This is
narrower than CLAUDE.md's description suggests; worth a doc update.