@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.
@@ -0,0 +1,473 @@
1
+ ---
2
+ name: agent-orchestrator
3
+ description: >-
4
+ Use when building an agent system — an orchestrator, an LLM-powered tool, a chatbot with tool
5
+ use, an AI pipeline — or when metering and billing the LLM access it burns. Covers tool-
6
+ calling loops, multi-stage pipelines with human checkpoints, provider routing with fallback
7
+ and retry, four-layer memory with confidence decay, context budgets, sub-agent coordination
8
+ and error hierarchies; for resale: tiered wallets, the single markup boundary, two-phase
9
+ commit across a database and a provider API, spend-delta polling, budget and loop guardrails,
10
+ per-tenant key lifecycle. Triggers - "agent", "orchestrator", "tool calling", "sub-agent",
11
+ "LLM router", "fallback chain", "human in the loop", "memory layer", "LLM billing", "token
12
+ wallet", "агент", "оркестратор", "суб-агент", "роутер моделей", "человек в цикле", "слой
13
+ памяти", "биллинг LLM", "лимит бюджета". Not for a single LLM call in a script, or for prompt
14
+ wording.
15
+ ---
16
+
17
+ # Agent Orchestrator — Production Best Practices
18
+
19
+ Battle-tested patterns from a production multi-agent system. Apply these when building any agent
20
+ orchestrator, LLM-powered tool system, or agentic workflow.
21
+
22
+ ## Architecture Overview
23
+
24
+ ```
25
+ User Question
26
+
27
+ ConversationalAgent (thin wrapper, backward-compat)
28
+
29
+ OrchestratorAgent.run(AgentContext)
30
+ ├─ Complexity check → simple (tool loop) or complex (pipeline)
31
+ ├─ Context loading (parallel: staleness, MCP sources, KB check)
32
+ ├─ History trimming
33
+ ├─ Context budget allocation
34
+ ├─ System prompt construction (dynamic, capability-aware)
35
+ └─ Execution:
36
+ ├─ SIMPLE: iterative LLM tool-calling loop
37
+ │ LLM → tool calls → sub-agent dispatch → results → LLM → ... → final text
38
+ └─ COMPLEX: multi-stage pipeline
39
+ QueryPlanner → ExecutionPlan → StageExecutor → checkpoints → final
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 1. The Orchestrator Pattern
45
+
46
+ ### Shared Context Object
47
+
48
+ Pass a single immutable-ish context object to all sub-agents:
49
+
50
+ ```python
51
+ @dataclass
52
+ class AgentContext:
53
+ project_id: str
54
+ user_question: str
55
+ chat_history: list[Message]
56
+ llm_router: LLMRouter # provider abstraction with retry/fallback
57
+ tracker: WorkflowTracker # SSE event emitter for real-time UI
58
+ workflow_id: str # unique ID for this request
59
+ connection_config: ... | None # external resource config
60
+ user_id: str | None
61
+ preferred_provider: str | None # e.g. "openrouter"
62
+ model: str | None # e.g. "openai/gpt-4o"
63
+ extra: dict[str, Any] # pipeline_action, flags, overrides
64
+ ```
65
+
66
+ **Key principles:**
67
+ - Sub-agents never modify context — they return typed results
68
+ - Provider/model preferences flow down from user → project defaults → app defaults
69
+ - `extra` dict carries pipeline state, flags like `_skip_complexity`, session IDs
70
+
71
+ ### Sub-Agent Protocol
72
+
73
+ Every sub-agent extends a base class:
74
+
75
+ ```python
76
+ class BaseAgent(ABC):
77
+ @abstractmethod
78
+ async def run(self, context: AgentContext, **kwargs) -> AgentResult: ...
79
+
80
+ @property
81
+ @abstractmethod
82
+ def name(self) -> str: ...
83
+
84
+ @staticmethod
85
+ def accum_usage(total, usage): ... # merge token counters
86
+ ```
87
+
88
+ Typed result subclasses per agent (e.g. `SQLAgentResult` with `query`, `results`, `attempts`).
89
+
90
+ ---
91
+
92
+ ## 2. Tool-Calling Loop (Simple Path)
93
+
94
+ The core agent loop pattern:
95
+
96
+ ```python
97
+ max_iter = settings.max_orchestrator_iterations # e.g. 10
98
+ for iteration in range(max_iter):
99
+ # 1. Context pressure management
100
+ messages, did_trim = trim_loop_messages(messages, context_window)
101
+ if should_wrap_up(messages, context_window):
102
+ messages.append(Message(role="system",
103
+ content="IMPORTANT: Stop making tool calls. Compose final answer now."))
104
+
105
+ # 2. LLM call with retry
106
+ llm_resp = await llm_call_with_retry(messages, tools, provider, model)
107
+
108
+ # 3. No tool calls = final answer
109
+ if not llm_resp.tool_calls:
110
+ final_text = llm_resp.content
111
+ break
112
+
113
+ # 4. Dispatch tool calls
114
+ messages.append(Message(role="assistant", content=llm_resp.content,
115
+ tool_calls=llm_resp.tool_calls))
116
+
117
+ # 5. Parallel execution (except sequential-only tools)
118
+ if len(llm_resp.tool_calls) > 1 and not has_sequential_tool:
119
+ results = await asyncio.gather(
120
+ *(handle_tool(tc, context) for tc in llm_resp.tool_calls),
121
+ return_exceptions=True)
122
+ else:
123
+ results = [await handle_tool(tc, context) for tc in llm_resp.tool_calls]
124
+
125
+ # 6. Append tool results
126
+ for tc, (text, sub_result) in zip(llm_resp.tool_calls, results):
127
+ messages.append(Message(role="tool", content=text,
128
+ tool_call_id=tc.id, name=tc.name))
129
+ else:
130
+ # Max iterations reached — compose partial answer from gathered data
131
+ final_text = "I reached maximum analysis steps. Here is what I found..."
132
+ ```
133
+
134
+ **Critical details:**
135
+ - **Parallel tool dispatch**: Use `asyncio.gather` for independent tools, sequential for stateful ones (e.g. data processing that depends on prior query results)
136
+ - **Wrap-up injection**: At ~70% context capacity, inject a system message telling the LLM to stop making tools calls and give a final answer
137
+ - **In-loop trimming**: At ~80% capacity, collapse older assistant+tool pairs into one-liner summaries
138
+ - **Token limit recovery**: On `LLMTokenLimitError`, compress to 60% and retry once. If still fails, return partial answer
139
+ - **Max iterations guard**: Always have a hard limit. On exhaustion, compose best-effort answer from data gathered so far
140
+
141
+ ---
142
+
143
+ ## 3. Meta-Tools (Orchestrator-Level)
144
+
145
+ Define tools that **delegate to sub-agents**, not execute directly:
146
+
147
+ ```python
148
+ QUERY_DATABASE_TOOL = Tool(
149
+ name="query_database",
150
+ description="Query the connected database. Handles SQL generation, validation, execution.",
151
+ parameters=[ToolParameter(name="question", type="string", description="Data question")]
152
+ )
153
+ ASK_USER_TOOL = Tool(
154
+ name="ask_user",
155
+ description="Ask the user a structured clarification question.",
156
+ parameters=[
157
+ ToolParameter(name="question", type="string", ...),
158
+ ToolParameter(name="question_type", type="string",
159
+ enum=["yes_no", "multiple_choice", "free_text"]),
160
+ ToolParameter(name="options", type="string", required=False),
161
+ ]
162
+ )
163
+ ```
164
+
165
+ **Assemble tools dynamically** based on available capabilities:
166
+
167
+ ```python
168
+ def get_tools(*, has_db=False, has_kb=False, has_mcp=False) -> list[Tool]:
169
+ tools = []
170
+ if has_db:
171
+ tools.extend([QUERY_DB, PROCESS_DATA, MANAGE_RULES, ASK_USER])
172
+ if has_kb:
173
+ tools.append(SEARCH_CODEBASE)
174
+ if has_mcp:
175
+ tools.append(QUERY_MCP)
176
+ return tools
177
+ ```
178
+
179
+ ---
180
+
181
+ ## 4. Sub-Agent Retry and Validation
182
+
183
+ Wrap every sub-agent call in retry + validation:
184
+
185
+ ```python
186
+ MAX_SUB_AGENT_RETRIES = 2
187
+
188
+ for attempt in range(MAX_SUB_AGENT_RETRIES + 1):
189
+ try:
190
+ result = await sub_agent.run(context, question=q)
191
+ validation = validator.validate(result)
192
+ if validation.passed or attempt == MAX_SUB_AGENT_RETRIES:
193
+ return format_for_llm(result, validation.warnings), result
194
+ continue # retry on validation failure
195
+ except AgentRetryableError:
196
+ if attempt < MAX_SUB_AGENT_RETRIES: continue
197
+ return "Failed after retries", None
198
+ except AgentFatalError as e:
199
+ return f"Fatal: {e}", None # no retry
200
+ ```
201
+
202
+ **Error hierarchy:**
203
+ ```
204
+ AgentError (base)
205
+ ├── AgentRetryableError → orchestrator retries with adjusted context
206
+ ├── AgentFatalError → unrecoverable (bad config, auth failure)
207
+ ├── AgentTimeoutError → retry with smaller context
208
+ └── AgentValidationError → sub-agent result failed quality checks
209
+ ```
210
+
211
+ **Result validation** (check before returning to user):
212
+ - SQL: query present? execution error? zero rows (warn)? slow query >30s (warn)?
213
+ - Viz: valid chart type? appropriate for data shape? (pie with 100 slices → bar)
214
+ - Knowledge: non-empty answer? source citations present?
215
+
216
+ ---
217
+
218
+ ## 5. Multi-Stage Pipeline (Complex Path)
219
+
220
+ For complex queries requiring multiple data steps:
221
+
222
+ ### Complexity Detection
223
+
224
+ Two-tier: fast heuristic + optional LLM check.
225
+
226
+ ```python
227
+ COMPLEXITY_KEYWORDS = ["summary table", "pivot", "cross-reference", "compare",
228
+ "for each", "step 1", "first find", "then"]
229
+
230
+ def detect_complexity(question, history) -> bool:
231
+ return any(kw in question.lower() for kw in COMPLEXITY_KEYWORDS)
232
+
233
+ async def detect_complexity_adaptive(question, llm, history) -> bool:
234
+ # Lightweight LLM call: "Is this simple or complex? Reply 'simple' or 'complex'."
235
+ resp = await llm.complete([...], max_tokens=10)
236
+ return "complex" in resp.content.lower()
237
+ ```
238
+
239
+ ### Pipeline Components
240
+
241
+ ```
242
+ QueryPlanner → (single LLM call) → ExecutionPlan (ordered stages)
243
+ StageExecutor → runs stages sequentially with validation + retry
244
+ StageValidator → checks data shape, row bounds, cross-stage consistency
245
+ StageContext → in-memory state (plan, results per stage, user feedback)
246
+ PipelineRun → DB-persisted state for resume/retry across requests
247
+ ```
248
+
249
+ ### Checkpoint Pattern (Human-in-the-Loop)
250
+
251
+ ```python
252
+ for idx, stage in enumerate(plan.stages):
253
+ result = await execute_with_retries(stage, context)
254
+ validation = validator.validate(stage, result, stage_ctx)
255
+
256
+ if not validation.passed:
257
+ retried = await retry_failed_validation(stage, context, validation)
258
+ if retried is None:
259
+ return StageFailedResult(stage, validation) # ask user
260
+ result = retried
261
+
262
+ stage_ctx.set_result(stage.id, result)
263
+
264
+ if stage.checkpoint:
265
+ persist_to_db(pipeline_run_id, stage_ctx)
266
+ return CheckpointResult(stage, result) # pause for user review
267
+ # User responds: "continue" | "modify" | "retry"
268
+ ```
269
+
270
+ ### Pipeline Resume
271
+
272
+ ```python
273
+ async def resume_pipeline(resume_info, context):
274
+ pipeline_run = load_from_db(resume_info["pipeline_run_id"])
275
+ plan = ExecutionPlan.from_json(pipeline_run.plan_json)
276
+ stage_ctx = StageContext.from_persistence(...)
277
+
278
+ resume_from = current_idx + 1 if action == "continue" else current_idx
279
+ return await executor.execute(plan, context, resume_from=resume_from, stage_ctx=stage_ctx)
280
+ ```
281
+
282
+ ---
283
+
284
+ ## 6. LLM Provider Routing
285
+
286
+ A router in front of the providers, not a provider client in front of the app:
287
+ attempt in order, fall through on failure, and surface one error hierarchy
288
+ upward so the caller cannot tell which vendor answered.
289
+
290
+ Read `references/llm-proxy-billing.md` → **Model routing and fallbacks** for the
291
+ fallback chain and per-provider retry with exponential backoff, and its
292
+ **Guardrails** section for budgets, loop detection and auto-pause.
293
+
294
+ **Three traps that cost real money:**
295
+
296
+ - **A retry loop and a fallback chain multiply.** Three providers with three
297
+ retries each is nine calls for one prompt; cap the total attempts, not the
298
+ per-provider ones.
299
+ - **Health checks that only run on failure never recover.** A provider marked
300
+ unhealthy needs a scheduled probe, or the chain permanently runs one provider
301
+ short and nobody sees it — the requests still succeed.
302
+ - **Model selection has three levels** — the request, the tenant, the system
303
+ default — and a tenant override that silently loses to a request parameter is
304
+ how a cheap model ends up billed at a premium one's rate.
305
+ ## 7. Multi-Layer Memory System
306
+
307
+ Four layers, each with a different lifetime and a different reason to exist:
308
+
309
+ | Layer | Scope | Lives | Holds |
310
+ |---|---|---|---|
311
+ | 1 Chat history | per session | minutes | the turns, trimmed to a token budget |
312
+ | 2 Working memory | per resource | days | what this task has established so far |
313
+ | 3 Long-term learnings | per resource | months | what worked, with a confidence score |
314
+ | 4 Insights | per project | permanent | conclusions that outlived their resource |
315
+
316
+ Read `references/patterns.md` for the data models, **Confidence Management**
317
+ (how a learning decays and when it is retired), **Learning Extraction
318
+ Heuristics**, **Fuzzy Deduplication** and **Conflict Resolution** — the four
319
+ mechanisms that decide what actually enters layers 3 and 4.
320
+
321
+ **The trap is the budget, not the storage.** Every layer competes for the same
322
+ context window, so allocation has to be decided per call rather than per layer:
323
+ a session that trims chat history to fit a large set of learnings has quietly
324
+ chosen old generalities over what the user said sixty seconds ago. Give layer 1
325
+ a floor.
326
+ ## 8. Self-Learning Feedback Loops
327
+
328
+ ### Cycle 1: Automatic (Validation Loop)
329
+
330
+ After every SQL execution cycle, heuristic extractors analyze the attempt sequence:
331
+
332
+ | Extractor | Detects | Creates |
333
+ |-----------|---------|---------|
334
+ | Table preference | Wrong table A fixed to B | "Use `B` instead of `A`" |
335
+ | Column correction | column_not_found → suggested col | "Use `full_name` not `user_name`" |
336
+ | Format discovery | Division by 100/1000 added | "Amounts in cents, divide by 100" |
337
+ | Schema gotcha | `deleted_at IS NULL` added | "Soft-delete: filter active records" |
338
+ | Performance hint | Timeout fixed by LIMIT/date filter | "Always add LIMIT to this table" |
339
+
340
+ LLM-based deep analysis (3+ attempts, 1hr cooldown) for cross-query patterns.
341
+
342
+ ### Cycle 2: User Feedback
343
+
344
+ ```python
345
+ # Thumbs down → analyze_negative_feedback() → learning
346
+ # Data validation:
347
+ # confirmed → store benchmark
348
+ # approximate → benchmark + session note (deviation details)
349
+ # rejected → learning + note + flag stale benchmark
350
+ # Categorize rejection: currency/format → data_format, filter → schema_gotcha,
351
+ # table → table_preference, join → schema_gotcha
352
+ ```
353
+
354
+ ### Cycle 3: Knowledge Lifecycle
355
+
356
+ - **Decay**: stale learnings -0.02/month, notes -0.1/60 days, insights -0.05/30 days
357
+ - **Conflict resolution**: negation flips deactivate old conflicting lessons
358
+ - **Global promotion**: patterns on 2+ resources promoted project-wide
359
+
360
+ ---
361
+
362
+ ## 9. Observability (SSE Event Streaming)
363
+
364
+ Real-time progress via `WorkflowTracker`:
365
+
366
+ ```python
367
+ class WorkflowTracker:
368
+ # In-memory event bus with asyncio.Queue subscribers
369
+ async def begin(pipeline, context) -> workflow_id
370
+ async def emit(wf_id, step, status, detail)
371
+ async def end(wf_id, agent, status, detail)
372
+
373
+ @asynccontextmanager
374
+ async def step(wf_id, step_name, description):
375
+ # Emits started/completed/failed with elapsed_ms
376
+
377
+ # Event types:
378
+ # pipeline_start/end, thinking, token (streaming), orchestrator:llm_call,
379
+ # orchestrator:sql_agent, orchestrator:llm_retry, orchestrator:warning
380
+ ```
381
+
382
+ Stream final answer text in chunks for typing effect:
383
+
384
+ ```python
385
+ async def stream_tokens(wf_id, text, chunk_size=12):
386
+ for i in range(0, len(text), chunk_size):
387
+ await tracker.emit(wf_id, "token", "streaming", text[i:i+chunk_size])
388
+ ```
389
+
390
+ ---
391
+
392
+ ## 10. Dynamic System Prompts
393
+
394
+ Build system prompts dynamically based on available capabilities:
395
+
396
+ ```python
397
+ def build_system_prompt(*, project_name, db_type, has_connection, has_kb, table_map,
398
+ project_overview, recent_learnings):
399
+ sections = [f"You are an AI data assistant for '{project_name}'."]
400
+ sections.append("AVAILABLE CAPABILITIES:")
401
+ if has_connection:
402
+ sections.append("- query_database: ... SQL agent handles everything")
403
+ sections.append("- process_data: ... enrich/aggregate/filter")
404
+ sections.append("- manage_rules: ... CRUD project rules")
405
+ if has_kb:
406
+ sections.append("- search_codebase: ... RAG over indexed code")
407
+
408
+ if table_map:
409
+ sections.append(f"DATABASE TABLES: {table_map}")
410
+ if recent_learnings:
411
+ sections.append(recent_learnings) # "AGENT LEARNINGS: ..."
412
+ sections.append("GUIDELINES: ...") # routing rules, verification protocol
413
+ return "\n".join(sections)
414
+ ```
415
+
416
+ **Data Verification Protocol** (inject when DB connected):
417
+ - First-time metrics: ask user "Do these numbers match expectations?"
418
+ - Financial figures: mention units (cents vs dollars), ask for confirmation
419
+ - Anomalies: proactively explain and ask user to verify
420
+ - Rejected data: investigate discrepancy, record finding as learning
421
+
422
+ ---
423
+
424
+ ## 11. Clarification Requests (ask_user)
425
+
426
+ Interrupt the tool loop to ask the user:
427
+
428
+ ```python
429
+ async def handle_ask_user(tc, context, wf_id):
430
+ payload = {"question": ..., "question_type": "multiple_choice",
431
+ "options": [...], "context": "why I'm asking"}
432
+ raise _ClarificationRequestError(json.dumps(payload))
433
+ # Caught in orchestrator.run() → returns AgentResponse(response_type="clarification_request")
434
+ # Frontend renders special UI, user responds, next message continues flow
435
+ ```
436
+
437
+ ---
438
+
439
+ ## Checklist — Building a New Orchestrator
440
+
441
+ - [ ] Shared `AgentContext` dataclass with all sub-agents
442
+ - [ ] `BaseAgent` protocol with typed results + `accum_usage()`
443
+ - [ ] Tool-calling loop with max iterations guard
444
+ - [ ] In-loop context trimming (80% compress, 70% wrap-up)
445
+ - [ ] Parallel tool dispatch where independent, sequential where stateful
446
+ - [ ] Sub-agent retry with validation (retryable vs fatal errors)
447
+ - [ ] Multi-provider LLM router with fallback chain + health checks
448
+ - [ ] Per-provider retry with exponential backoff (respect `retry_after`)
449
+ - [ ] Unified LLM error hierarchy with `user_message` property
450
+ - [ ] Context budget manager (priority-based allocation)
451
+ - [ ] Dynamic system prompt (capability-aware, learning-injected)
452
+ - [ ] Chat history trimming (tool condensing, LLM summarization)
453
+ - [ ] Working memory (session notes, fuzzy dedup, confidence decay)
454
+ - [ ] Long-term learnings (heuristic extraction, conflict resolution, global patterns)
455
+ - [ ] Insight memory (lifecycle, trust scoring, decay)
456
+ - [ ] Feedback pipeline (thumbs, data validation → learnings/notes/benchmarks)
457
+ - [ ] SSE event streaming for real-time progress
458
+ - [ ] Complexity detection (heuristic + adaptive LLM)
459
+ - [ ] Multi-stage pipeline with checkpoints and resume
460
+ - [ ] `ask_user` clarification mechanism
461
+ - [ ] Graceful degradation (partial answers on context overflow or max iterations)
462
+
463
+ ---
464
+
465
+ ## References
466
+
467
+ Load these when the task reaches them — the checklist above is the map, these
468
+ are the territory.
469
+
470
+ | File | Read it when |
471
+ |---|---|
472
+ | [`references/patterns.md`](references/patterns.md) | you need the **data models and algorithms**: message and result protocols, pipeline models, the SQL validation loop, context-window sizes and token estimation, learning-extraction heuristics, confidence lifecycle, fuzzy dedup, conflict resolution, cross-resource transfer, the no-LLM suggestion engine |
473
+ | [`references/llm-proxy-billing.md`](references/llm-proxy-billing.md) | the product **resells LLM access**: tiered wallets and where markup applies, two-phase commit against a provider API with compensating transactions, advisory locking, optimistic concurrency for reclaims, spend-delta polling and its three cases, budget/loop/auto-pause guardrails, per-tenant key lifecycle and healing, the refund waterfall, model routing |