From Static Profiles to Adaptive Policies
Most multi-agent workflows use a fixed profile: “use the balanced settings” or “always run in quality mode.” That works until you need to adapt — when a run is taking too long, when costs are spiralling, or when quality isn’t meeting the bar. The next step is a system that watches its own performance and adjusts the next run based on what it learned.
This guide builds a multi-agent article workflow in TypeScript that tracks every LLM call, every service request, and every cost down to the cent. It stores the results in PostgreSQL, and after each run, it derives an adaptive policy that tunes the next run’s research depth, scrape limit, and review passes based on measured performance. No machine learning, no bandit algorithms — just heuristics fed by real data.
1. What You’ll Build
The workflow runs five agents in sequence, the same as the basic LangGraph version:
- Researcher — fetches Google organic SERP results via DataForSEO and scrapes top pages via Jina Reader
- SEO strategist — creates a keyword brief, angle, audience, and outline from live data
- Writer — produces a full article draft
- Reviewer — scores the draft against quality and SEO criteria
- Publisher — converts the final draft into a WordPress REST API payload
What’s new: every call is metered, every cost is estimated, and the entire run is saved to PostgreSQL. After the run, an adaptive policy function analyses the collected metrics and adjusts parameters for the next run — deeper research if quality was high, shallower scrapes if costs were too steep, or more review passes if the reviewer rejected the draft.
Check the repo on GitHub: https://github.com/juustesout/typescript-learning-ai-agent
2. Project Setup
git clone https://github.com/juustesout/typescript-learning-ai-agent tsdatabase
cd tsdatabase
npm install
Dependencies: pg for PostgreSQL, typescript and @types/node for the compiler. The project uses no external agent framework — every model call goes through a 30-line MiniAgent class built on top of fetch.
3. Environment Variables
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o-mini
DATAFORSEO_API_KEY=base64-of-login:password
DATAFORSEO_BASE_URL=https://api.dataforseo.com
WP_BASE_URL=https://yourblog.com
WP_USERNAME=admin
WP_APPLICATION_PASSWORD=xxxx
JINA_API_KEY=jina_...
DESKTOP_DB_HOST=host.docker.internal
DESKTOP_DB_PORT=5433
DESKTOP_DB_NAME=xxxx
DESKTOP_DB_USER=xxx
DESKTOP_DB_PASSWORD=...
The loadEnv() function in src/database.ts and src/workflow.ts reads the .env file at module import time — no dotenv dependency needed.
4. The Metrics Collector
The heart of the data layer is the RunMetricsCollector class in src/metrics.ts. It records every call the workflow makes — LLM completions, DataForSEO requests, Jina scrapes, and WordPress API calls — with timestamps, durations, token counts, and cost estimates:
collector.recordCall({
kind: "llm",
provider: "openai",
model: "gpt-4o-mini",
durationMs: 2487,
inputTokens: 2358,
outputTokens: 312,
estimatedCostUsd: 0.0005,
status: "ok",
metadata: { node: "research" },
});
The collector also tracks node-level summaries. Each stage of the workflow (research, writer, review, publish) records its total duration, call count, cost, and a quality score:
collector.recordNode({
node: "research",
durationMs: 5954,
callCount: 5,
costUsd: 0.0037,
qualityScore: 80,
});
5. Cost Estimation
The estimateModelCostUsd() function in src/metrics.ts calculates LLM costs using known pricing per model:
const rateMap = {
"gpt-4o-mini": { input: 0.00000015, output: 0.0000006 },
"gpt-4o": { input: 0.0000025, output: 0.00001 },
"gpt-4.1-mini":{ input: 0.0000004, output: 0.0000016 },
};
Service costs are calculated separately. DataForSEO costs are estimated at $0.001 per page, Jina at roughly $50 per billion tokens (effectively negligible for a single run). The calculateServiceCost() function keeps these estimates consistent across the codebase.
6. Agent Profiles
Four profiles define the operating constraints for a run. Each profile specifies a quality target, a maximum cost budget, a target latency, and a preferred traversal order:
const AGENT_PROFILES = {
budget: { qualityTarget: 68, maxCostUsd: 0.45, targetLatencyMs: 60000 },
balanced: { qualityTarget: 82, maxCostUsd: 0.9, targetLatencyMs: 45000 },
quality: { qualityTarget: 92, maxCostUsd: 1.8, targetLatencyMs: 60000 },
speed: { qualityTarget: 76, maxCostUsd: 0.6, targetLatencyMs: 25000 },
};
The chooseAgentProfile() function selects the best profile given constraints. If you set MAX_COST_USD=0.5 and MIN_QUALITY_SCORE=75, it will automatically pick the balanced profile. If you set MAX_LATENCY_MS=25000, it picks speed.
7. The Adaptive Policy
After each run, the deriveAdaptivePolicy() function in src/metrics.ts analyses the collected metrics and adjusts parameters for the next run. It reads the average quality score, cost, and latency from the current run and derives a new policy:
function deriveAdaptivePolicy({ profileName, nodeStats }) {
const averageQuality = (research.quality + writer.quality + review.quality) / 3;
const averageLatency = (research.latency + writer.latency + review.latency) / 3;
if (averageQuality >= 82 && averageLatency <= 6000) {
return { mode: "deep", researchDepth: 30, scrapeLimit: 8, reviewPasses: 3 };
}
if (averageLatency <= 3500) {
return { mode: "fast", researchDepth: 10, scrapeLimit: 3, reviewPasses: 1 };
}
if (averageCost <= 0.08) {
return { mode: "cheap", researchDepth: 10, scrapeLimit: 3, reviewPasses: 1 };
}
return { mode: "balanced", researchDepth: 20, scrapeLimit: 5, reviewPasses: 2 };
}
The policy is applied to the next run’s research depth, scrape limit, and maximum review passes. Over time, runs that consistently produce high-quality articles with low latency will naturally drift toward the “deep” mode, while runs with tight cost constraints will stay in “cheap” mode.
8. PostgreSQL Schema
The workflow_runs table stores every run with full JSONB data:
CREATE TABLE workflow_runs (
id SERIAL PRIMARY KEY,
topic TEXT NOT NULL,
research JSONB,
draft JSONB,
review JSONB,
publish_payload JSONB,
publish_result JSONB,
metrics JSONB,
policy JSONB,
decision_mode TEXT,
research_depth INTEGER,
scrape_limit INTEGER,
review_passes INTEGER,
decision_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The metrics column stores the full RunMetricsSummary — every call, every node summary, every cost. The policy column stores the adaptive policy that was derived from the run. This means you can query historical runs to see how the policy evolved over time:
SELECT topic, decision_mode, research_depth, scrape_limit, created_at
FROM workflow_runs
ORDER BY created_at DESC
LIMIT 10;
9. Running the Workflow
npm run build && node dist/demo.js "AI sales agents for SMBs"
The demo entrypoint prints the research, draft, review, publish result, and the full metrics summary:
{
"totalCalls": 9,
"totalDurationMs": 24065,
"totalInputTokens": 7235,
"totalOutputTokens": 2439,
"totalCostUsd": 0.0057,
"qualityScore": 85,
"nodes": [
{ "node": "research", "durationMs": 5954, "callCount": 5, "costUsd": 0.0037, "qualityScore": 80 },
{ "node": "writer", "durationMs": 9298, "callCount": 1, "costUsd": 0.0010, "qualityScore": 82 },
{ "node": "review", "durationMs": 1290, "callCount": 1, "costUsd": 0.0002, "qualityScore": 90 },
{ "node": "publish", "durationMs": 6343, "callCount": 1, "costUsd": 0.0007, "qualityScore": 88 }
]
}
10. Architecture Notes
- No external agent framework — the
MiniAgentclass calls the OpenAI-compatible API directly viafetch. Every model call, token count, and cost is explicitly tracked. - Rule-based adaptation, not ML — the adaptive policy uses simple thresholds on measured metrics, not a learned model. This keeps the system transparent and predictable while still being data-driven.
- Full audit trail — every run is persisted with its complete call log, cost breakdown, and derived policy. You can trace which decisions led to which outcomes.
- Profile auto-selection — the
chooseAgentProfile()function picks the best profile from cost, quality, and latency constraints, so you don’t need to manually switch between profiles. - Retry and fallback — both the DataForSEO call and Jina scraping wrap their operations in a simple retry loop, returning structured error objects instead of throwing.
11. Next Steps: From Heuristics to Bandits
The adaptive policy in this project is a heuristic bridge. It works well for simple cases — if quality is high, go deeper; if cost is high, stay shallow — but it can’t learn complex tradeoffs. The natural evolution is the contextual bandit system implemented in the companion project, which replaces the rule-based policy with a learned routing graph that picks the optimal path at every decision point using historical edge statistics stored in PostgreSQL.
The key insight: the metrics collector, PostgreSQL schema, and cost estimation code in this project are the foundation that the bandit builds on. The bandit reads from the same edge_stats table, uses the same cost estimates, and records the same call metrics. The only difference is the decision layer — rules vs. learned utility.
12. Get the Code
The full source code is on GitHub: https://github.com/juustesout/typescript-learning-ai-agent
Clone it, configure your API keys and PostgreSQL connection, and run npm run demo. The workflow will research, write, review, publish, and save the full run data to your database. Query the workflow_runs table to see how the policy evolves over time.
