@ssheleg/agent-stack 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/SECURITY.md +63 -0
- package/bin/agent-stack.js +87 -0
- package/package.json +41 -0
- package/plugins/agent-stack/.claude-plugin/plugin.json +24 -0
- package/plugins/agent-stack/skills/agent-orchestrator/SKILL.md +473 -0
- package/plugins/agent-stack/skills/agent-orchestrator/references/llm-proxy-billing.md +255 -0
- package/plugins/agent-stack/skills/agent-orchestrator/references/patterns.md +365 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Reselling LLM access — metering, wallets and guardrails
|
|
2
|
+
|
|
3
|
+
When your product gives users LLM access and bills for it, you are running a
|
|
4
|
+
proxy with a wallet behind it. The failure modes are not model failures: they
|
|
5
|
+
are **double-credited transfers**, **spend you discovered after it happened**,
|
|
6
|
+
and **a runaway loop that emptied a balance overnight**. This reference is the
|
|
7
|
+
provider-neutral shape of that problem, drawn from a production system built on
|
|
8
|
+
OpenRouter's Management API — the API calls are named where they are concrete,
|
|
9
|
+
the patterns hold for any upstream that issues per-tenant keys with limits.
|
|
10
|
+
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
- [The tiered wallet](#the-tiered-wallet)
|
|
14
|
+
- [Two-phase commit across a DB and an external API](#two-phase-commit-across-a-db-and-an-external-api)
|
|
15
|
+
- [Serializing concurrent transfers](#serializing-concurrent-transfers)
|
|
16
|
+
- [Optimistic concurrency for reclaims](#optimistic-concurrency-for-reclaims)
|
|
17
|
+
- [Discovering spend you do not control](#discovering-spend-you-do-not-control)
|
|
18
|
+
- [Guardrails: budgets, loops, auto-pause](#guardrails-budgets-loops-auto-pause)
|
|
19
|
+
- [Key lifecycle and healing](#key-lifecycle-and-healing)
|
|
20
|
+
- [The refund waterfall](#the-refund-waterfall)
|
|
21
|
+
- [Model routing and fallbacks](#model-routing-and-fallbacks)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## The tiered wallet
|
|
26
|
+
|
|
27
|
+
Money does not move in one hop. Model it as tiers, and be explicit about **which
|
|
28
|
+
boundary applies the markup** — this is the single most common source of
|
|
29
|
+
accounting drift.
|
|
30
|
+
|
|
31
|
+
| Tier | Holds | Denominated in |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| Account wallet | what the user paid you | user-facing USD |
|
|
34
|
+
| Tenant reserve | allocated to one bot / workspace / project | user-facing USD, 1:1 with the account |
|
|
35
|
+
| Upstream key limit | what the provider will actually let them spend | provider USD, after markup |
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
markup = 0.30 // your cut, from config, never hardcoded
|
|
39
|
+
|
|
40
|
+
toKeyAmount(userUsd) = userUsd * (1 - markup) // $50 user → $35 on the key
|
|
41
|
+
toUserAmount(keyUsd) = keyUsd / (1 - markup) // $35 key → $50 shown back
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Apply markup at exactly one boundary** — reserve → key — and keep account ↔
|
|
45
|
+
reserve at 1:1. Two boundaries applying a cut is how a balance silently shrinks
|
|
46
|
+
every time a user moves money around without spending anything.
|
|
47
|
+
|
|
48
|
+
Keep the same two functions on the client. A UI that recomputes the conversion
|
|
49
|
+
with its own copy of the constant will disagree with the server the first time
|
|
50
|
+
the constant changes.
|
|
51
|
+
|
|
52
|
+
**Thresholds worth naming rather than inlining:**
|
|
53
|
+
|
|
54
|
+
| Constant | Typical | Purpose |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `LOW_BALANCE_THRESHOLD` | $10 | trigger reserve → key transfer + notify |
|
|
57
|
+
| `CRITICAL_BALANCE_THRESHOLD` | $1 | permit auto-topup from the account wallet |
|
|
58
|
+
| `AUTO_TOPUP_AMOUNT` | $15 | ceiling pulled per auto-topup |
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Two-phase commit across a DB and an external API
|
|
63
|
+
|
|
64
|
+
You have a database you can roll back and an HTTP API you cannot. Order matters,
|
|
65
|
+
and so does what you do when step 2 fails.
|
|
66
|
+
|
|
67
|
+
**DB first, API second, compensate on failure:**
|
|
68
|
+
|
|
69
|
+
1. Acquire the lock (below).
|
|
70
|
+
2. Read fresh balances **inside** the transaction — not before it.
|
|
71
|
+
3. Compute the transfer and apply the markup once.
|
|
72
|
+
4. Zero the source tier, increment the destination, write an audit row.
|
|
73
|
+
5. Commit.
|
|
74
|
+
6. Call the provider to raise the key limit.
|
|
75
|
+
7. **On API failure: a compensating transaction restores every DB value and
|
|
76
|
+
writes a `compensation` audit row.**
|
|
77
|
+
|
|
78
|
+
The alternative — API first, DB second — leaves money on the key that your
|
|
79
|
+
ledger does not know about, and no amount of retrying finds it again. The
|
|
80
|
+
compensating transaction is not optional politeness; it is the only thing that
|
|
81
|
+
makes step 6 recoverable.
|
|
82
|
+
|
|
83
|
+
Log both the intent and the compensation. An audit trail that records only
|
|
84
|
+
successes cannot answer "where did the $35 go" six weeks later.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Serializing concurrent transfers
|
|
89
|
+
|
|
90
|
+
Two requests topping up the same tenant at the same time will both read the same
|
|
91
|
+
starting balance and both write their own total. Take a lock keyed on the tenant,
|
|
92
|
+
in the same transaction:
|
|
93
|
+
|
|
94
|
+
```sql
|
|
95
|
+
SELECT pg_advisory_xact_lock(hashtext(tenant_id || '_key'));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Transaction-scoped (`_xact_`) so it releases on commit **or** rollback — a
|
|
99
|
+
session-scoped lock survives a failed transaction and deadlocks the retry.
|
|
100
|
+
|
|
101
|
+
Every operation that moves this tenant's money takes the same lock: top-up,
|
|
102
|
+
reclaim, pull-back, refund. A single unlocked path makes the other five
|
|
103
|
+
pointless.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Optimistic concurrency for reclaims
|
|
108
|
+
|
|
109
|
+
Locking protects concurrent writers in your database. It does not protect you
|
|
110
|
+
from a change the **provider** made while your transaction was open — a spend
|
|
111
|
+
that landed, a limit an operator edited in their dashboard.
|
|
112
|
+
|
|
113
|
+
For any operation that reads a provider value, acts, and writes back: snapshot
|
|
114
|
+
the value before the external call and re-read it after. If it moved, abort
|
|
115
|
+
rather than reconcile.
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
before = db.keyLimit
|
|
119
|
+
info = await provider.getKey(hash)
|
|
120
|
+
if (info.limit !== before) throw new ConcurrentModification() // do not guess
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Aborting costs a retry. Guessing double-credits.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Discovering spend you do not control
|
|
128
|
+
|
|
129
|
+
The provider deducts from the key as calls happen. Nothing notifies you. You
|
|
130
|
+
discover spend by **polling a cumulative counter and taking the delta**:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
delta = currentUsage - lastRecordedUsage
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Three cases, and only the first is obvious:
|
|
137
|
+
|
|
138
|
+
- `lastRecordedUsage == 0 && currentUsage > 0` → **seed the baseline, record
|
|
139
|
+
nothing.** Recording it charges the tenant for everything spent before you
|
|
140
|
+
started watching.
|
|
141
|
+
- `currentUsage > lastRecordedUsage` → record `delta`, then immediately enforce
|
|
142
|
+
budgets (below).
|
|
143
|
+
- `currentUsage < lastRecordedUsage` → the key was recreated. **Resync the
|
|
144
|
+
baseline, record nothing.** A negative delta treated as spend credits money
|
|
145
|
+
that was never returned.
|
|
146
|
+
|
|
147
|
+
Sync your stored limit from the provider's authoritative value on the same pass —
|
|
148
|
+
under the lock, with a re-read, so the sync does not clobber a transfer that
|
|
149
|
+
landed mid-poll.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Guardrails: budgets, loops, auto-pause
|
|
154
|
+
|
|
155
|
+
Three independent mechanisms, one shared action. Keep them separate in
|
|
156
|
+
configuration and unified in effect.
|
|
157
|
+
|
|
158
|
+
**Budget limits** — per-tenant daily and monthly caps with their own counters and
|
|
159
|
+
reset timestamps. `recordSpend()` increments and calls `enforceBudgetLimit()`
|
|
160
|
+
in the same breath; enforcement that runs on a schedule rather than on the write
|
|
161
|
+
is enforcement that arrives after the money is gone.
|
|
162
|
+
|
|
163
|
+
**Loop detection** — spend *velocity* over a rolling window against a
|
|
164
|
+
configurable multiplier of the tenant's normal rate. This is what catches an
|
|
165
|
+
agent that started calling itself; a daily cap will also catch it, tomorrow.
|
|
166
|
+
|
|
167
|
+
**Auto-pause** — an absolute per-tenant threshold, plus a user-level aggregate
|
|
168
|
+
across all their tenants. The aggregate exists because ten tenants each just
|
|
169
|
+
under their limit is a bill nobody approved.
|
|
170
|
+
|
|
171
|
+
All three converge on one function:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
pauseBot(tenantId, cause, reason)
|
|
175
|
+
1. set the pause timestamp for THAT cause (budgetPausedAt / loopPausedAt / …)
|
|
176
|
+
2. disable the upstream key
|
|
177
|
+
3. write an audit row naming the cause
|
|
178
|
+
4. notify the user
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Separate timestamps per cause, because resuming must know what paused it: a
|
|
182
|
+
daily budget reset should not un-pause a tenant that a loop detector stopped.
|
|
183
|
+
Reset jobs zero their own counters, clear **their own** timestamp, and re-enable
|
|
184
|
+
only if no other pause is still set.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Key lifecycle and healing
|
|
189
|
+
|
|
190
|
+
Per-tenant keys go missing — deleted in a dashboard, orphaned by a failed
|
|
191
|
+
provision, expired. Treat the key as cache, not truth.
|
|
192
|
+
|
|
193
|
+
**Before every deploy**, validate and heal:
|
|
194
|
+
|
|
195
|
+
1. Check the key against the provider's own auth endpoint (the raw key, not the
|
|
196
|
+
management hash).
|
|
197
|
+
2. Dead → create a fresh one with the same limit, update your stored key, hash
|
|
198
|
+
and limit.
|
|
199
|
+
3. If the tenant was running, trigger a redeploy so the new key takes effect.
|
|
200
|
+
|
|
201
|
+
The polling job does the same when the Management API returns 404 for a hash it
|
|
202
|
+
holds.
|
|
203
|
+
|
|
204
|
+
**Management keys are not inference keys.** They do key CRUD and nothing else;
|
|
205
|
+
a management key sent to a completions endpoint fails in a way that reads like
|
|
206
|
+
an auth bug for an hour.
|
|
207
|
+
|
|
208
|
+
**The full key is returned exactly once, at creation.** Store it then or issue a
|
|
209
|
+
new one. Every provider does this and every integration learns it the same way.
|
|
210
|
+
|
|
211
|
+
Name keys after the tenant (`tenant:{id}:{name}`) — the provider dashboard is
|
|
212
|
+
where you will be debugging at 2am, and `sk-or-v1-…` tells you nothing.
|
|
213
|
+
|
|
214
|
+
Wire the lifecycle explicitly, one row per event: enabled → create + fund;
|
|
215
|
+
disabled → disable; subscription cancelled → disable + reclaim; deleted →
|
|
216
|
+
delete + reclaim; balance depleted → disable + notify; budget reset → re-enable.
|
|
217
|
+
A table like that in your own docs is what stops the eleventh event from being
|
|
218
|
+
handled three different ways.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## The refund waterfall
|
|
223
|
+
|
|
224
|
+
A payment refund has to come out of somewhere, and the money has usually moved.
|
|
225
|
+
Pull in a fixed priority, most-liquid first:
|
|
226
|
+
|
|
227
|
+
1. tenant reserve — purchased pool
|
|
228
|
+
2. tenant reserve — subscription pool
|
|
229
|
+
3. remaining balance on the upstream key (markup-adjusted, via the provider API)
|
|
230
|
+
4. account wallet
|
|
231
|
+
|
|
232
|
+
Any unrecoverable remainder is **logged for manual review**, not silently
|
|
233
|
+
forgiven and not left to make a balance negative. A tenant who spent the money
|
|
234
|
+
already is a business decision, not an arithmetic one.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Model routing and fallbacks
|
|
239
|
+
|
|
240
|
+
Map your public model names to provider ids in **one** function, and give every
|
|
241
|
+
provider a default and a fallback:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
toUpstreamModel(provider, model) // "gpt-4o" → "openai/gpt-4o"
|
|
245
|
+
getDefaultModel(provider) // when the caller names none
|
|
246
|
+
getFallbackModels(provider) // ordered, tried on 5xx / overload
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Three levels of model selection, in precedence order: the caller's explicit
|
|
250
|
+
choice → the tenant's configured default → the application default. Resolve them
|
|
251
|
+
in that order in one place, and log which level won — "why did it use that
|
|
252
|
+
model" is otherwise unanswerable.
|
|
253
|
+
|
|
254
|
+
See `patterns.md` for the retry, health-check and error-hierarchy patterns these
|
|
255
|
+
routing calls sit inside.
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# Agent Orchestrator — Reference Guide
|
|
2
|
+
|
|
3
|
+
Extended patterns, data models, and implementation details.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [Data Models](#data-models)
|
|
8
|
+
- [Pipeline Data Models](#pipeline-data-models)
|
|
9
|
+
- [Validation Loop (SQL Execution)](#validation-loop-sql-execution)
|
|
10
|
+
- [Context Window Sizes](#context-window-sizes)
|
|
11
|
+
- [Learning Extraction Heuristics](#learning-extraction-heuristics)
|
|
12
|
+
- [Confidence Management](#confidence-management)
|
|
13
|
+
- [Fuzzy Deduplication Pattern](#fuzzy-deduplication-pattern)
|
|
14
|
+
- [Conflict Resolution Pattern](#conflict-resolution-pattern)
|
|
15
|
+
- [Cross-Resource Learning Transfer](#cross-resource-learning-transfer)
|
|
16
|
+
- [Suggestion Engine (No LLM Cost)](#suggestion-engine-no-llm-cost)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## Data Models
|
|
20
|
+
|
|
21
|
+
### Message Protocol
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
@dataclass
|
|
25
|
+
class Message:
|
|
26
|
+
role: str # system | user | assistant | tool
|
|
27
|
+
content: str
|
|
28
|
+
tool_call_id: str | None = None # for role="tool" responses
|
|
29
|
+
name: str | None = None # tool name
|
|
30
|
+
tool_calls: list[ToolCall] | None = None # for role="assistant" with tools
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ToolCall:
|
|
34
|
+
id: str # unique ID per call
|
|
35
|
+
name: str # tool function name
|
|
36
|
+
arguments: dict[str, Any] # parsed JSON arguments
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Tool:
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
parameters: list[ToolParameter]
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ToolParameter:
|
|
46
|
+
name: str
|
|
47
|
+
type: str # "string", "number", "boolean"
|
|
48
|
+
description: str
|
|
49
|
+
required: bool = True
|
|
50
|
+
enum: list[str] | None = None
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class LLMResponse:
|
|
54
|
+
content: str = ""
|
|
55
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
56
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
57
|
+
model: str = ""
|
|
58
|
+
provider: str = ""
|
|
59
|
+
finish_reason: str = ""
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Agent Result Protocol
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
@dataclass
|
|
66
|
+
class AgentResult:
|
|
67
|
+
status: str = "success" # success | error | no_result
|
|
68
|
+
token_usage: dict[str, int] = field(default_factory=dict)
|
|
69
|
+
error: str | None = None
|
|
70
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
# Typed subclass example:
|
|
73
|
+
@dataclass
|
|
74
|
+
class SQLAgentResult(AgentResult):
|
|
75
|
+
query: str | None = None
|
|
76
|
+
query_explanation: str | None = None
|
|
77
|
+
results: QueryResult | None = None
|
|
78
|
+
attempts: list[QueryAttempt] = field(default_factory=list)
|
|
79
|
+
insights: list[dict] = field(default_factory=list)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Orchestrator Response
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
@dataclass
|
|
86
|
+
class AgentResponse:
|
|
87
|
+
answer: str = ""
|
|
88
|
+
query: str | None = None
|
|
89
|
+
query_explanation: str | None = None
|
|
90
|
+
results: QueryResult | None = None
|
|
91
|
+
viz_type: str = "text"
|
|
92
|
+
viz_config: dict = field(default_factory=dict)
|
|
93
|
+
knowledge_sources: list[RAGSource] = field(default_factory=list)
|
|
94
|
+
error: str | None = None
|
|
95
|
+
workflow_id: str | None = None
|
|
96
|
+
token_usage: dict = field(default_factory=dict)
|
|
97
|
+
llm_provider: str = ""
|
|
98
|
+
llm_model: str = ""
|
|
99
|
+
response_type: str = "text" # text|sql_result|knowledge|error
|
|
100
|
+
# |clarification_request|stage_checkpoint
|
|
101
|
+
# |stage_failed|pipeline_complete
|
|
102
|
+
tool_call_log: list[dict] = field(default_factory=list)
|
|
103
|
+
suggested_followups: list[str] = field(default_factory=list)
|
|
104
|
+
insights: list[dict] = field(default_factory=list)
|
|
105
|
+
context_usage_pct: int = 0
|
|
106
|
+
staleness_warning: str | None = None
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Pipeline Data Models
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
@dataclass
|
|
115
|
+
class PlanStage:
|
|
116
|
+
stage_id: str # unique ID
|
|
117
|
+
description: str # human-readable
|
|
118
|
+
tool: str # "query_database" | "search_codebase" | "analyze_results"
|
|
119
|
+
question: str # sub-question for the stage
|
|
120
|
+
depends_on: list[str] # stage IDs this depends on
|
|
121
|
+
checkpoint: bool = False # pause for user review after this stage
|
|
122
|
+
validation: StageValidation | None = None
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class StageValidation:
|
|
126
|
+
min_rows: int | None = None
|
|
127
|
+
max_rows: int | None = None
|
|
128
|
+
required_columns: list[str] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ExecutionPlan:
|
|
132
|
+
question: str
|
|
133
|
+
stages: list[PlanStage]
|
|
134
|
+
def to_json(self) -> str: ...
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_json(cls, raw: str) -> ExecutionPlan: ...
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class StageResult:
|
|
140
|
+
stage_id: str
|
|
141
|
+
status: str = "success" # success | error | skipped
|
|
142
|
+
query: str | None = None
|
|
143
|
+
query_result: QueryResult | None = None
|
|
144
|
+
answer: str | None = None
|
|
145
|
+
error: str | None = None
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Validation Loop (SQL Execution)
|
|
151
|
+
|
|
152
|
+
The full SQL execution cycle with pre/post validation and repair:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
for attempt in range(1, max_retries + 1):
|
|
156
|
+
1. PRE-VALIDATE
|
|
157
|
+
└─ Schema check: tables exist? columns exist? types compatible?
|
|
158
|
+
└─ If fails → repair query via LLM → continue
|
|
159
|
+
|
|
160
|
+
2. SAFETY CHECK
|
|
161
|
+
└─ Read-only enforcement, DML blocking
|
|
162
|
+
└─ If unsafe → fail immediately (no retry)
|
|
163
|
+
|
|
164
|
+
3. EXPLAIN DRY-RUN (optional)
|
|
165
|
+
└─ Run EXPLAIN, check for full table scans on huge tables
|
|
166
|
+
└─ If problematic → repair query → continue
|
|
167
|
+
|
|
168
|
+
4. EXECUTE
|
|
169
|
+
└─ Run query against user's database
|
|
170
|
+
└─ Classify errors: table_not_found, column_not_found, syntax_error,
|
|
171
|
+
timeout, permission_denied, connection_error
|
|
172
|
+
└─ If retryable error → enrich context + repair → continue
|
|
173
|
+
└─ If fatal → fail
|
|
174
|
+
|
|
175
|
+
5. POST-VALIDATE
|
|
176
|
+
└─ Sanity checks on results
|
|
177
|
+
└─ If fails → repair → continue
|
|
178
|
+
|
|
179
|
+
6. SUCCESS → extract learnings from attempt history
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Context Window Sizes
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
MODEL_CONTEXT_WINDOWS = {
|
|
188
|
+
"gpt-4o": 128_000,
|
|
189
|
+
"gpt-4o-mini": 128_000,
|
|
190
|
+
"gpt-4-turbo": 128_000,
|
|
191
|
+
"gpt-4": 8_192,
|
|
192
|
+
"gpt-3.5-turbo": 16_385,
|
|
193
|
+
"claude-sonnet-4-20250514": 200_000,
|
|
194
|
+
"claude-3-5-sonnet-20241022": 200_000,
|
|
195
|
+
"claude-3-haiku-20240307": 200_000,
|
|
196
|
+
"claude-3-opus-20240229": 200_000,
|
|
197
|
+
}
|
|
198
|
+
DEFAULT_CONTEXT_WINDOW = 16_000
|
|
199
|
+
|
|
200
|
+
# Token estimation: tiktoken for OpenAI models, ~4 chars/token fallback
|
|
201
|
+
def estimate_tokens(text):
|
|
202
|
+
try:
|
|
203
|
+
import tiktoken
|
|
204
|
+
return len(tiktoken.get_encoding("cl100k_base").encode(text))
|
|
205
|
+
except Exception:
|
|
206
|
+
return max(1, len(text) // 4)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Learning Extraction Heuristics
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
class LearningAnalyzer:
|
|
215
|
+
def _detect_table_preference(attempts, question):
|
|
216
|
+
# For consecutive attempt pairs where attempt[i] failed and attempt[i+1] succeeded:
|
|
217
|
+
# Compare tables used. If old_tables - new_tables and new_tables - old_tables:
|
|
218
|
+
# → "Use `new_table` instead of `old_table` for {topic}"
|
|
219
|
+
|
|
220
|
+
def _detect_column_correction(attempts):
|
|
221
|
+
# If attempt[i] has column_not_found error with column name X
|
|
222
|
+
# and attempt[i+1] no longer uses X:
|
|
223
|
+
# → "Column `X` doesn't exist on `table`. Use `Y` instead."
|
|
224
|
+
|
|
225
|
+
def _detect_format_discovery(attempts):
|
|
226
|
+
# If fixed query adds "/ 100" or "/ 1000" that wasn't in failed query:
|
|
227
|
+
# → "Column `amount` stores cents. Divide by 100."
|
|
228
|
+
|
|
229
|
+
def _detect_schema_gotcha(attempts):
|
|
230
|
+
# If fixed query adds "deleted_at IS NULL" or schema prefix:
|
|
231
|
+
# → "Table uses soft-delete. Always filter active records."
|
|
232
|
+
|
|
233
|
+
def _detect_performance_hint(attempts):
|
|
234
|
+
# If timeout error fixed by adding LIMIT or date filter:
|
|
235
|
+
# → "Table can timeout. Always add LIMIT and date filter."
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Confidence Management
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
LEARNING CONFIDENCE:
|
|
244
|
+
Initial: 0.6
|
|
245
|
+
Confirmed: +0.1 (cap 1.0)
|
|
246
|
+
Applied: tracked (times_applied counter)
|
|
247
|
+
Contradicted: -0.3
|
|
248
|
+
Stale (30d): -0.02/month
|
|
249
|
+
Deactivated: below 0.2
|
|
250
|
+
|
|
251
|
+
SESSION NOTE CONFIDENCE:
|
|
252
|
+
Initial: 0.7
|
|
253
|
+
Confirmed: +0.1 (cap 1.0)
|
|
254
|
+
Verified: +0.15 (exempt from decay)
|
|
255
|
+
Stale (60d): -0.1 per cycle
|
|
256
|
+
Floor: 0.1
|
|
257
|
+
|
|
258
|
+
INSIGHT CONFIDENCE:
|
|
259
|
+
Initial: 0.5
|
|
260
|
+
Resurfaced: +0.05
|
|
261
|
+
Confirmed: +0.15
|
|
262
|
+
Dismissed: -0.2
|
|
263
|
+
Stale (30d): -0.05 per cycle
|
|
264
|
+
Expired: below 0.15
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Fuzzy Deduplication Pattern
|
|
270
|
+
|
|
271
|
+
Used across all memory layers:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from difflib import SequenceMatcher
|
|
275
|
+
|
|
276
|
+
THRESHOLD = 0.75 # learnings/notes; 0.80 for insights
|
|
277
|
+
|
|
278
|
+
async def find_similar(session, connection_id, category, subject, text):
|
|
279
|
+
candidates = await load_existing(session, connection_id, category, subject)
|
|
280
|
+
text_lower = text.strip().lower()
|
|
281
|
+
best_match, best_ratio = None, 0.0
|
|
282
|
+
for c in candidates:
|
|
283
|
+
ratio = SequenceMatcher(None, c.text.strip().lower(), text_lower).ratio()
|
|
284
|
+
if ratio >= THRESHOLD and ratio > best_ratio:
|
|
285
|
+
best_match, best_ratio = c, ratio
|
|
286
|
+
return best_match
|
|
287
|
+
|
|
288
|
+
# On match: bump confidence +0.1, keep longer text, set is_active=True
|
|
289
|
+
# On no match: create new entry
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Conflict Resolution Pattern
|
|
295
|
+
|
|
296
|
+
Detect when new learning contradicts existing ones:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
CONFLICT_INDICATORS = {"use", "prefer", "always", "never", "should",
|
|
300
|
+
"instead", "not", "avoid", "correct", "wrong"}
|
|
301
|
+
|
|
302
|
+
def resolve_conflicts(existing_learnings, new_lesson, new_confidence):
|
|
303
|
+
new_keywords = {w for w in new_lesson.lower().split() if w in CONFLICT_INDICATORS}
|
|
304
|
+
for old in existing_learnings:
|
|
305
|
+
old_keywords = {w for w in old.lesson.lower().split() if w in CONFLICT_INDICATORS}
|
|
306
|
+
shared = new_keywords & old_keywords
|
|
307
|
+
if not shared: continue
|
|
308
|
+
|
|
309
|
+
has_negation_flip = (
|
|
310
|
+
("not" in new_keywords) != ("not" in old_keywords) or
|
|
311
|
+
("never" in new_keywords) != ("never" in old_keywords) or
|
|
312
|
+
("avoid" in new_keywords) != ("avoid" in old_keywords))
|
|
313
|
+
|
|
314
|
+
if has_negation_flip and old.confidence <= new_confidence:
|
|
315
|
+
old.is_active = False # superseded
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Cross-Resource Learning Transfer
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
async def get_cross_connection_learnings(session, connection_id, exclude_hashes):
|
|
324
|
+
project_id = get_project_for_connection(connection_id)
|
|
325
|
+
sibling_ids = get_sibling_connections(project_id, exclude=connection_id)
|
|
326
|
+
# Only transfer schema_gotcha and performance_hint (universally applicable)
|
|
327
|
+
transferable = await load_learnings(sibling_ids, categories={"schema_gotcha", "performance_hint"},
|
|
328
|
+
min_confidence=0.6)
|
|
329
|
+
return [f"- [from sibling] {l.lesson} [{int(l.confidence*100)}%]"
|
|
330
|
+
for l in transferable if l.lesson_hash not in exclude_hashes][:8]
|
|
331
|
+
|
|
332
|
+
async def promote_global_patterns(session, connection_id):
|
|
333
|
+
# Find learnings appearing on 2+ independent connections
|
|
334
|
+
patterns = await query(
|
|
335
|
+
SELECT lesson_hash, MAX(lesson), MAX(confidence), COUNT(DISTINCT connection_id)
|
|
336
|
+
WHERE is_active AND confidence >= 0.7
|
|
337
|
+
GROUP BY lesson_hash HAVING COUNT(DISTINCT connection_id) >= 2)
|
|
338
|
+
# Exclude already-known patterns, format as prompt lines
|
|
339
|
+
return [f"- [global, seen on {p.conn_count} DBs] {p.lesson}" for p in patterns][:5]
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Suggestion Engine (No LLM Cost)
|
|
345
|
+
|
|
346
|
+
Template-based follow-up suggestions:
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
FOLLOWUP_TEMPLATES = [
|
|
350
|
+
"Show this as a pie chart",
|
|
351
|
+
"Break this down by month",
|
|
352
|
+
"Compare with the previous period",
|
|
353
|
+
"Show only the top 5 results",
|
|
354
|
+
"What is the trend over time?",
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
def generate_followups(query, columns, row_count) -> list[str]:
|
|
358
|
+
pool = list(FOLLOWUP_TEMPLATES)
|
|
359
|
+
if has_aggregate_keywords(query):
|
|
360
|
+
pool.extend(["Show percentage breakdown", "Average instead of count?"])
|
|
361
|
+
if row_count > 1 and len(columns) >= 2:
|
|
362
|
+
pool.append(f"Sort by {columns[-1]} descending")
|
|
363
|
+
random.shuffle(pool)
|
|
364
|
+
return pool[:3]
|
|
365
|
+
```
|