Unified Agentic Sourcing

UAS is the "hands" of Sourcy's sourcing stack. Given a request it generates specs, drives an agentic search ⇄ verify loop over 1688 / Alibaba, then cleans, matches, gates and ranks products into a shortlist — and hands it back to the conversational Brain (agent-core).

TL;DR: The LLM agent owns only judgment — which keyword, when to stop, which tool. The deterministic stages own the craft — price gate, cleanup, spec match, hard gates, ranking. All state lives in one in-memory RunContext blackboard; every LLM call self-meters cost into a CostLedger; an eval harness replays the real pipeline against cached fixtures so quality & spend are measurable without paying per run. Jump to the yellow-bottle simulation →
1

The two-brain model

Who decides what vs who finds the how

agent-core is the conversational Brain: it understands the customer and decides what to source. UAS is the Hands: it owns the craft of actually finding strong products. They communicate over an async HTTP + webhook contract — UAS replies 202 + jobId immediately and posts a job_completed webhook when the run finishes.

agent-core · THE BRAIN Conversational · understands the customer · decides WHAT to source Builds the Sourcing Request (SR), specs & reference images POST /api/sourcing-jobs → 202 + jobId webhook: job_completed UAS · THE HANDS Owns the CRAFT of finding good products Own LLM stack OpenAI + Gemini Cost ledger tokens + USD / stage Global catalog reuse clean+match Runs + blackboard resumable Dashboard + eval /eval
Solid = sync request · dashed = async webhook
2

Module map

Layered src/ — data flows down the stack, results flow up · click a layer

ENTRYPOINTS cli.ts · http/server.ts (Bun router) · http/dashboard.ts · http/playground.ts · eval/run.ts INTEGRATION · Brain ⇄ UAS contract sourcing-job · customer-selection · guide(P2) · brain-contracts (Zod) · sr-context · processed-product AGENT · orchestration + state sourcing-brain (6-tool) · search-agent (3-tool, prod) · auto (stream) · phases (back-half) run-context ◀ THE BLACKBOARD (AsyncLocalStorage) · result · snapshot STAGES · the pipeline craft specs → search ⇄ verify → pricing → cleanup → matching → suppliers matching/: matcher · rank · product-type-gate · customisation-gate · supplier-score tools/* wrap phases · skills/* supply the prompts FOUNDATIONS llm/ (call · models · cost-ledger) · domain/ (Zod) · catalog/ · runs/ · config.ts · lib/ cross-cutting carriers: RunContext blackboard + CostLedger, both via AsyncLocalStorage
Click any layer for its role ↑ · lower layers never import upper layers

Click a layer above

Each layer's responsibility and key files will appear here.

3

The sourcing pipeline

One product flows left→right · the agent loops the search box until a good batch exists · click a stage

request + ref images SPECS gen / adopt veto + rerank AGENTIC SEARCH LOOP search 1688 / Alibaba TMAPI verify light screen title+image ≥ minVerified? no → vary keywords, loop yes PRICE GATE determ · reversible CLEANUP translate + structure MATCH per-spec + vision recheck TYPE GATE kill wrong category CUSTOM GATE custom SRs only RANK verdict→confirm→mfg→score SHORTLIST PP-API → Brain Fallback · phaseExpandOnNoMatch() 0 shortlisted & 0 pending after MATCH → generate ≤5 new keywords → re-search → re-clean → re-match (circuit-broken at 5)
Click any stage for files + the in/out contract ↑

Click a stage above

Each stage's file, function and data contract appears here.

Stage-by-stage contract

StageFile · functionIn → Out
specsstages/specs/generate.ts · generateSpecs()
or sr-context.ts · adopt Brain specs
request + images → productType, vetoSpecs[], rerankSpecs[], keywords
searchstages/search/search.ts · searchProducts()keyword + platforms → NormalizedProduct[] (deduped on blackboard)
verifystages/verify/verify.ts · verifyBatch()hits + specs → pass/fail/uncertain pre-screen
price gatestages/pricing/price-gate.ts · evaluatePrice()price + target + FX → adheres? (reversible exclude)
cleanupstages/cleanup/cleanup.ts · cleanupProduct()NormalizedProduct → CleanedProduct (translated, structured, SKUs)
matchstages/matching/matcher.ts · matchProduct()CleanedProduct + specs → verdict + scores (+ vision recheck)
type gatematching/product-type-gate.tsproductType + candidates → kill wrong category
custom gatematching/customisation-gate.tscustom need + suppliers → kill non-customisable
rankmatching/rank.ts · rankResults()results[] → ordered shortlist (top-N)
4

The agent loop

A Mastra tool-calling agent on OpenAI · stateless tools mutate one shared blackboard

Production (Brain job) runs a 3-tool search agent (generate_specs, search_products, verify_candidates) then a deterministic back-half. The CLI/eval /source path runs the full 6-tool SourcingBrain that drives the entire pipeline itself. Either way the model only chooses which tool, what keywords, when to stop — the craft lives in the stages.

MODEL REASONS → picks a tool gpt-4.1-mini · instructions = skills/orchestration.ts generate_specs → phaseSpecs search_products → phaseSearch verify_candidates → phaseVerify cleanup_products 6-tool only match_specs 6-tool only rank_select 6-tool only tool result appended to context RunContext BLACKBOARD · AsyncLocalStorage candidates · verified · cleaned · matched · priceExcluded · shortlist · ledger · log STOPPING CONDITIONS — any trips → finalize partial & rank maxToolIterations 30 (Mastra maxSteps) · maxTokensPerRun 4M (BudgetExceededError) · searchDeadline 240s · jobDeadline 600s per-call: 60s timeout + 2 retries (AbortSignal in llm/call.ts) · minCandidates 50 · minVerified 20 · maxSearchAttempts 10
Tools are thin wrappers over phases.ts → stages/* · all state lands in the blackboard
Why a blackboard? Tools return nothing the agent has to thread through — they mutate RunContext. The model carries only decisions. Because state serializes to uas_blackboards, a finished run stays resumable: the P2 /guide channel can relax_veto, expand_keywords, re_source or request_replacements without re-sourcing from scratch.
5

Deep dive · the price gate

evaluatePrice() — deterministic, no LLM, generous by design

The gate parses the SR's target_price string, converts currencies through an FX table, and compares the product's cheapest variant against target × (1 + tolerancePct/100) (default tolerance 10%). It is fail-open: a missing price or an unknown currency is never excluded. Exclusions are reversible — they sit in run.priceExcluded and can be released.

product · target · FX price.min used (cheapest variant) price on listing? !price no adheres · NOT excluded "no price on listing" yes both currencies in FX table? no adheres · not comparable "unknown currency X" yes productUSD ≤ targetUSD × 1.10 ? yes within target ✓ kept in pipeline no → priceExcluded "exceeds target by N%" reversible · withheld not killed
Three fail-open exits before any exclusion · the only hard "no" needs a comparable, over-limit price
InputResultWhy
target $1.50, product min 10 CNYwithin≈$1.40 ≤ limit $1.65
target $1.50, product min 12 CNYexcluded≈$1.68 > limit $1.65 → "exceeds by 12%"
target $1.50, product currency XYZkeptunknown currency → not comparable → fail-open
no target_price on SRkeptgate never runs
Parsing is lenient. parseTargetPrice() reads "5 USD", "$5", "0.2 CNY" or bare "5"; RMB→CNY is normalised; a bare number assumes the config default currency. Comparison always uses the product's minimum price so a product is only excluded when even its cheapest tier is over budget.
6

Deep dive · cleanup

One consolidated Gemini Flash-lite call + a concurrent item-detail fetch

The Python product-processing pipeline ran ~8 sequential Gemini steps per product (translate, extract, dedupe, normalise, categorise…). UAS collapses those into one structured Flash-lite call that returns a translated, summarised, categorised product with a deduped attribute list — and runs it concurrently with a TMAPI item-detail fetch so resolving SKU variants adds no extra wall-clock time.

NormalizedProduct title · price · image supplier · platform Promise.all runObject · Gemini Flash-lite title+image → titleTranslated · titleSummarized productCategory · weight/dims · attributes[] each attr: name·value·confidence·source(title|image|both) resolveDetail · TMAPI item-detail SKU variants (where real skuid lives) + better supplier signal (company_type → isFactory) skipped if hit already carries variants merge isFactory = A.sup OR B.sup stamp dims on every variant CleanedProduct cached in uas_products version-gated reuse
The LLM call and the SKU/supplier fetch overlap — cleanup costs one Flash-lite call of latency, not two
why weight always ≥ 0.1 kg
contract safety
agent-core's SR-item migration throws "Missing required fields" on a falsy weight_per_unit_kg. applyEstimatedDimensions() always falls back to 0.1 kg so the migration contract holds even when the LLM estimates nothing.
why the OR on isFactory
supplier signal merge
1688 carries the factory flag in the search payload; Alibaba only exposes it via item-detail's company_type. Cleanup ORs both sources so Alibaba sellers don't all default to "trader" — which would silently sink them in supplier scoring.
cache global catalog reuse
skip-clean across SRs
Cleaned products are keyed platform:itemId in uas_products and gated by SOURCING_CATALOG_VERSION. A product seen in a prior SR returns instantly — bump the version to force a re-clean of everything.
7

Match · vision recheck · gates · rank

Where verdicts and the final order are decided

matchProduct() scores every veto + rerank spec, then runs a vision recheck: for any image-source veto whose text verdict is match or unknown, a stronger Gemini Vision model looks at the product photo — the image's verdict wins on colour / shape / finish. Then the hard gates fire and ranking orders what survives.

VERDICT (per product) any veto NOT_MATCH → eliminated (any score) confidence ≥ 0.6 → shortlisted else → pending (possible) confidence = Σ matched-veto-weight / Σ all-veto-weight HARD GATES TYPE GATE — kill wrong category (LLM, fail-open: no type → no gate) CUSTOM GATE — kill non-customisable (custom SRs only · fail-open) PRICE GATE — withhold over-budget (deterministic · reversible) RANK (tie-breakers) 1 · verdict: shortlisted › pending › elim 2 · fully-confirmed › has-unknowns 3 · factory / manufacturer › trader 4 · combinedScore ↓ combined = rerankScore + supplierScore
A strong product isn't starved by one unknown veto — only a confirmed NOT_MATCH eliminates
Recall tuning. Because confidence is a weighted ratio of confirmed vetoes (not a count), a product that confirms its high-weight vetoes can still shortlist even with a single unconfirmable low-weight veto — instead of being demoted to "possible" forever. The vision recheck exists to convert those "unknown" colour/shape vetoes into confirmed yes/no from the photo.
8

LLM stack & cost control

Every call flows through one chokepoint that self-meters spend

StageModelTier · role
orchestrator (agent)gpt-4.1-mini · OpenAIreasoning · tool-calling
spec-gen · matchgemini-3.x-flashstructured generation
cleanup · verify · gatesgemini-3.x-flash-litecheap bulk work
vision recheckgemini-2.5-flashimage truth on visual vetoes

All calls go through llm/call.ts (runObject / runText), which enforces the 60s timeout, retries and per-run token budget, then writes a UsageEntry into the per-run CostLedger (carried via AsyncLocalStorage). The ledger rolls up byStage and byModel into SourcingResult.cost — surfaced on the dashboard and in every eval report.

9

The eval harness

Replays the real pipeline against cached fixtures · judges always live

13 scenarios scenarios.ts · 3 query sr-cases.ts · 10 SR unsourceable · custom · selection run.ts · per scenario FixtureStore(mode, fixtures/<id>.json) key = sha256(stage + params) withFixtures → runSut() REAL pipeline · miss → live + self-heal record ALL_SCORERS (outside fixtures) 7 structural gate · 3 LLM judges live report.ts → HTML + JSON eval/reports/eval-<ts>.html eval:publish → eval/published/latest.html committed · ships in Docker · served at GET /eval replay vs record bun run eval — replay cached fixtures · free · deterministic · a miss falls through to live and self-heals the fixture (warned, not silent) bun run eval --record — live calls, seed fixtures · costs money Judges bypass fixtures (stage starts "judge") so grading is always honest · structural scorers gate pass/fail, judges are quality signals
SR buckets test the hard cases: don't fabricate matches for unsourceable/niche · resolve every customer-selection pick
10

Simulation · "a yellow 100 ml water bottle"

Step-by-step, the way the live system would actually behave

Suppose the Brain sends UAS a sourcing job: "yellow 100 ml water bottle" with target_price: "1.5 USD" and one reference photo. Here is what happens, stage by stage. Note the twist 100 ml introduces — at that size, marketplaces surface perfume atomisers and spray bottles, which the type gate is built to catch.

The funnel — counts dropping through the pipeline

SEARCH · 1688 + Alibaba, 2 rounds 62 candidates VERIFY · light title+image screen (≥ minVerified 20 ✓) 24 kept PRICE GATE · target $1.50, limit $1.65 — 2 over-budget withheld 22 priced-in CLEANUP · translate + structure + SKU fetch (8 in parallel) 22 cleaned MATCH + vision recheck · 3 eliminated (1 amber photo, 2 wrong capacity) 19 matched TYPE GATE · kill 3 perfume/spray atomisers (wrong category) 16 in-type RANK → top-12 · 6 shortlisted + 10 possible SHORTLIST
Illustrative counts — the shape (broad search → screened → gated → ranked) is the real behaviour

Step by step

1 SPECS — generate or adopt productType "water bottle" · veto {colour=yellow w.5 image-source, capacity≈100 ml w.5} · rerank {food-grade w.3, leak-proof w.2} keywords: 黄色水瓶 100ml · mini water bottle 100ml · small yellow drink bottle 2 SEARCH ⇄ VERIFY (agent loop) Round 1: 38 hits → verify keeps 14 (< 20). Agent varies keyword + locale → Round 2: +24 hits → 24 kept ✓ agent decides "enough veto-passers" and exits the loop · deadlines/token budget would also force exit 3 PRICE GATE — deterministic limit = $1.50 × 1.10 = $1.65. Two bottles at 12 CNY (≈$1.68) → withheld to priceExcluded ("exceeds by 12%", reversible) one bottle with no listed price → kept (fail-open) 4 CLEANUP — 1 Flash-lite call/product, 8 in parallel "黄色便携水瓶100毫升" → "Yellow portable water bottle 100 ml" · attrs {colour:yellow .9 img, capacity:100 ml .8 title} · item-detail → skuid + factory flag cached to uas_products — a repeat SR reuses these instantly 5 MATCH + VISION RECHECK colour is image-source → Gemini Vision opens each photo. One "yellow" listing is actually amber → veto NOT_MATCH → eliminated two bottles confirm 250 ml not 100 ml → eliminated · the rest confirm colour+capacity → shortlisted/pending 6 TYPE GATE — the 100 ml twist 3 hits are 100 ml perfume atomisers / spray bottles — not drinking bottles. Type gate marks them wrong-category → eliminated (custom gate skipped — this SR isn't a customisation request) 7 RANK → factory + fully-confirmed bottles rise · top-12 returned as PP-API shortlist + webhook to the Brain
The same blackboard accumulates through every step; the cost ledger totals each LLM call as it goes
What the Brain receives. A ranked shortlist (6 confirmed + 10 possible), a priceExcluded list of 2 withheld-but-recoverable bottles, per-product veto/rerank detail, and a cost summary. If suppliers later go quiet, the Brain can call /guide request_replacements and UAS serves more from its retained product bank — no re-sourcing.
If it were unsourceable. Had the request been a niche branded item, the agent would loop search, hit the keyword-expansion fallback (≤5 new keywords), find nothing confirmable, and return an honest no_verified / all_eliminated outcome rather than fabricating a shortlist — exactly what the eval's "unsourceable" bucket checks for.
?

FAQ

Common questions

Why two agents (3-tool vs 6-tool)?

The production Brain job uses a 3-tool search agent then a deterministic back-half (cleanup → match → gates → rank) — that path is predictable and cheap to reason about. The 6-tool SourcingBrain (used by CLI/eval//source) lets the model drive the whole pipeline, which is useful for exploration but less controllable. Same stages underneath.

Why is the price gate reversible instead of just deleting over-budget products?

Budget is the Brain's call, not UAS's. UAS withholds over-budget bottles into priceExcluded so the Brain (or a human) can release them — e.g. if nothing else qualifies, a 12%-over bottle may be acceptable. Deleting them would throw away recoverable options.

Why does the image override the text on colour?

Marketplace titles lie or mistranslate ("yellow" for amber/gold). For visual vetoes — colour, shape, finish — the product photo is ground truth, so the vision recheck's verdict wins. Text-confirmed NOT_MATCH is never re-checked (already out); "unknown" and "match" get the photo's verdict.

Why do fixtures "self-heal" on replay instead of failing?

Agent tool-choices are non-deterministic, so a perfectly recorded fixture can miss when the agent picks a slightly different keyword. Rather than fail the eval, the harness goes live for that one call, records it, and prints a warning — so the suite stays runnable while surfacing drift.

How is cost tracked without instrumenting every stage?

Every model call funnels through llm/call.ts, which writes a usage entry into a per-run CostLedger carried on AsyncLocalStorage. Stages don't track cost themselves — they just call the chokepoint, and the ledger rolls up byStage/byModel automatically.