History-aware routing
This page explains how the AI router uses past calls, not only the current message. Read it when follow-ups must go where earlier messages went, or when you want the router to learn from corrections.
A router that only sees the current message gets these cases wrong:
- a follow-up from the same customer, which should go where the previous event went;
- a customer who has already escalated twice today;
- a conversation that moved from a billing question to a bug report.
Past calls are kept per route and per key in a store, Redis in production. They are sent to the model as conversation turns. History is designed for the System One decision engine, whose state takes several role-tagged messages natively. The generative engine reads the same history as chat messages.
Turn it on
Add a history block to an AI router, and a store to keep the records in.
stores:
- name: redis-main
type: redis
addr: redis:6379
password: "{{secrets.REDIS_PASSWORD}}"
timeout: 50ms
routes:
- name: smart-triage
from: [webhooks]
router:
type: ai
# instructions, state, candidates, thresholds ...
history:
store: redis-main # default: the first store
key: "{{ .json.customer_id }}" # whose history: customer, conversation, session, device
window: { max_entries: 10, max_age: 24h } # what is sent to the model
ttl: 7d # what is kept in the store (mandatory)
record: { state: redacted } # redacted (default) | summary | none
sticky: { enabled: true, follow_up_threshold: 0.8 }
on_store_error: ignore # ignore (default) | fallback
key is a per-message template. Everyone who renders the same key shares one history. window
bounds what the model reads: the newest max_entries (10 by default), no older than max_age.
ttl bounds what is kept in the store. Without a stores: section, hikyaku run keeps history
in memory, which is enough on one replica and for local runs.
What is recorded
One record per routed call. It is appended after delivery, so the outcome is known:
type CallRecord struct {
MsgID string `json:"msg_id"`
At time.Time `json:"at"`
Route string `json:"route"`
Key string `json:"key"` // the rendered history.key
State string `json:"state,omitempty"` // the redacted state, a short summary, or empty
Decision []Answer `json:"decision"` // candidate, probability, chosen, margin
Model string `json:"model"` // which model in the fallback chain answered
DeliveredTo []string `json:"delivered_to"` // after destination fallbacks
Outcome string `json:"outcome"` // delivered | fallback | dlq | low_confidence
Feedback *Feedback `json:"feedback,omitempty"` // a human or system correction
}
The Redis layout
The Redis store keeps history like this:
- One list per route and key:
{prefix}:{relay}:{route}:hist:{key}.prefixis the store’skey_prefix(hikyakuby default), andrelayis the spec’sname. The{key}part is a hash tag, so a Redis Cluster keeps every entry of a key on one slot. - Each write is one pipeline:
LPUSH,LTRIMtomax_entries × 2, andEXPIREto thettl. - A secondary hash,
…:msg:{msg_id} → key, lets feedback find a record by its message ID. It has the same TTL. - Writes are asynchronous: they go through a bounded in-process queue with retries, and they
never block delivery. Reads are bounded by the store’s
timeout(50ms by default), so a slow Redis cannot stall routing. - Standalone Redis (
addr), Sentinel (sentinel: { master_name, addrs }) and Cluster (cluster: { addrs }) all work, with ACL users, passwords and TLS.
The same store backs the AI decision cache, source dedupe keys, and the retry-budget and AI call-budget counters. One Redis holds all the shared state. That is what makes several replicas behave like one; see Deploy.
When the store fails
History makes routing better, but it never makes routing unavailable. on_store_error says what
happens when the store cannot be read:
on_store_error: ignore(the default) routes the message without history.on_store_error: fallbackhands the message torouter.fallbackinstead.
Either way, hikyaku_history_errors_total{op} is incremented. The store’s own circuit breaker
(on by default) stops hammering a dead Redis until it recovers.
How history reaches the model
The router turns the stored records into conversation turns, then asks its questions as usual.
Recent(key, window)fetches the last records within the window, oldest first.- The router builds the state as role-tagged turns:
- the system message is the route’s
instructions, plus one line explaining the history format; - each past call becomes a pair of turns: a
userturn with the stored state (or summary), and amodelturn that says what happened, such asrouted to support (p=0.91), outcome: delivered, pluscorrect destination: billingwhen a correction was recorded; - the current message is the last
userturn.
- the system message is the route’s
- The candidate questions are unchanged (one choice, or one yes/no per candidate). But they are now answered in the context of the history, so the probabilities reflect it.
input.max_bytescaps the total state. The oldest pairs of turns go first. The current message is shortened only once no history is left. System One servers may silently truncate a long state; this way, the part that is cut is the part that matters least.
hikyaku history show --route smart-triage --key c-42 prints a key’s history exactly as the
model will see it.
Sticky routing
Sticky routing sends a follow-up where the last call of the same key went, without asking the candidate questions again.
history:
# ...
sticky: { enabled: true, mode: model, follow_up_threshold: 0.8 }
With sticky.enabled, the router adds one built-in yes/no question: “Is the latest message a
follow-up to a previous call in this history?”
- If the probability is at least
follow_up_threshold(0.8 by default) and the last call was delivered, the message goes to the same destinations as last time. When the last call has a correction, it goes to the corrected ones instead. The candidate questions are skipped, which is cheaper and consistent. - Otherwise the candidate questions decide, as usual.
sticky.mode is model (the default, as above), always_last or off. always_last is pure
deterministic affinity within the window, with no model call. It works when every model is down,
so it also serves as a router fallback. The sticky router is the same
thing as a router of its own.
hikyaku_sticky_total{route,outcome} counts the follow-ups that stuck and the ones that did not.
The feedback loop
A correction says where a past message should have gone. Record one, and the router takes it into account for that key from then on. A correction comes in three ways:
-
POST /v1/feedbackon the admin API, with the admin token:curl -X POST http://localhost:9090/v1/feedback \ -H "Authorization: Bearer $HIKYAKU_ADMIN_TOKEN" \ -d '{"msg_id": "01JC3Z8Q6ZK3V0B9D3T5V7X9YB", "correct": ["billing"], "note": "refund, not a bug"}' -
hikyaku history correct <msg_id> --to billing --note "refund, not a bug"; -
a destination that answers with an
X-Hikyaku-Reroute: billingheader.
The correction updates the stored record. Every later call for that key sees
correct destination: billing in its history. So the model adapts per customer by in-context
learning (it learns from the examples in its input), with no retraining.
hikyaku_feedback_total{route} counts corrections.
To tune the global prompt and thresholds from real mistakes, export the corrections as an eval set and score the route on it:
hikyaku history export --labeled --since 30d --route smart-triage > cases.yaml
hikyaku ai test --route smart-triage --cases cases.yaml
Privacy
History stores message content, so these controls decide what is kept, and for how long.
record.state: redacted(the default) stores the state afterinput.redacthas removed its JSONPaths.record.state: summarystores a short template-rendered summary instead, such asrecord: { state: summary, summary: "{{ .json.type }} / {{ .json.subject }}" }.record.state: nonestores only the decision and the outcome.- The
ttlis mandatory: history is never kept forever. hikyaku history forget --route r --key kandDELETE /v1/history/{route}/{key}delete a key’s history, for right-to-be-forgotten requests.- Give hikyaku a dedicated Redis user with an ACL limited to its key prefix, and use TLS.
The store interface
Read this section if you want to write a store of your own. Stores implement two small
interfaces from the root module’s store/ package. The Redis implementation is its own module,
stores/redis (go-redis v9, with standalone, Sentinel and Cluster support). memory implements
both for local runs and tests.
type HistoryStore interface {
Append(ctx context.Context, route, key string, rec CallRecord, o AppendOptions) error
Recent(ctx context.Context, route, key string, w Window) ([]CallRecord, error)
Feedback(ctx context.Context, msgID string, fb Feedback) (CallRecord, error)
Forget(ctx context.Context, route, key string) error
Records(ctx context.Context, route string, since time.Time, fn func(CallRecord) error) error
}
type KV interface { // the cache, dedupe and counters
Get(ctx context.Context, k string) ([]byte, bool, error)
SetNX(ctx context.Context, k string, v []byte, ttl time.Duration) (bool, error)
Incr(ctx context.Context, k string, ttl time.Duration) (int64, error)
}
type Deleter interface { // optional: dedupe forgets the key of a message that failed
Delete(ctx context.Context, k string) error
}
Metrics
| Metric | What it counts |
|---|---|
hikyaku_history_reads_total{route,outcome} | history reads, by outcome |
hikyaku_history_errors_total{op} | store errors, by operation |
hikyaku_history_turns{route} | turns sent to the model (histogram) |
hikyaku_sticky_total{route,outcome} | sticky follow-up decisions |
hikyaku_feedback_total{route} | corrections recorded |