Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
ca09534
feat(assistant): integrity core + RAG foundation for the AI assistant
nedda76 Jun 19, 2026
8df37af
feat(assistant): system prompt + tool-result bridge
nedda76 Jun 19, 2026
cd1942f
feat(assistant): harden eop_fetch (validation + server-fixed base + s…
nedda76 Jun 19, 2026
c2f907d
build(deps): pin undici ^7.28.0 to clear the audit advisory
nedda76 Jun 19, 2026
1b8a67c
feat(assistant): renderer contract — format-by-hint + entity-ref links
nedda76 Jun 19, 2026
bb7410a
feat(assistant): source_link + emit_report shape validation
nedda76 Jun 19, 2026
7cc220b
feat(assistant): SDK-agnostic agent tool registry
nedda76 Jun 19, 2026
f4909ae
feat(assistant): wire the agent loop — /assistant/chat via Vercel AI SDK
nedda76 Jun 19, 2026
f24427b
docs(assistant): bring the README up to date with the wired agent loop
nedda76 Jun 20, 2026
4788618
docs(deps): sync undici advisory descriptions with #81
nedda76 Jun 20, 2026
ec10034
fix(web): harden the assistant agent loop
nedda76 Jun 20, 2026
40a102d
test(web): cover the assistant prompt-injection data boundary
nedda76 Jun 20, 2026
bc6250e
docs(web): flag the assistant provisioning deploy-gate
nedda76 Jun 20, 2026
cd07858
feat(web): add a fail-closed SQL AST guard for the assistant
nedda76 Jun 20, 2026
489e941
feat(web): harden report binding (links, cell sanitisation, E2)
nedda76 Jun 21, 2026
2f97d56
docs(web): lock BE↔FE assistant contracts + fixtures
nedda76 Jun 21, 2026
326b4a0
fix(web): withhold oversized eop_fetch responses
nedda76 Jun 21, 2026
1f70410
fix(web): bound assistant request and generation resources
nedda76 Jun 21, 2026
d868f66
fix(web): close low-severity assistant hardening items
nedda76 Jun 21, 2026
7666489
feat(web): scope run_sql to allowlisted tables + bound the query
nedda76 Jun 21, 2026
628862c
feat(web): per-IP rate limit on POST /assistant/chat
nedda76 Jun 21, 2026
5211b05
docs(web): record the #80 red-team hardening in the assistant README
nedda76 Jun 21, 2026
3b51526
fix(assistant): gate and sanitize model-controlled labels, title, and…
nedda76 Jun 22, 2026
ed0527c
fix(assistant): reject LIMIT offset, count form in AST guard (L1)
nedda76 Jun 22, 2026
03cfd3d
docs(assistant): document semantic_search entity namespace gap
nedda76 Jun 22, 2026
afe4cde
fix(assistant): measure eop_fetch cap in UTF-8 bytes, not character c…
nedda76 Jun 22, 2026
4c20a9c
Merge remote-tracking branch 'origin/main' into feat/ai-assistant-impl
nedda76 Jun 22, 2026
e88eb9a
build(deps): regenerate lockfile after main merge
nedda76 Jun 22, 2026
78b1657
fix(assistant): close table-valued-function and ON-less JOIN bypasses…
nedda76 Jun 24, 2026
c75080b
fix(assistant): harden the E2 number-gate and prose sanitizer
nedda76 Jun 24, 2026
1705201
fix(web): fail closed on the assistant rate limiter in production
nedda76 Jun 24, 2026
5101850
fix(assistant): measure the chat body cap in UTF-8 bytes, not UTF-16 …
nedda76 Jun 24, 2026
a1c03a2
fix(assistant): validate eop dates strictly against Sofia-local today
nedda76 Jun 24, 2026
298588a
test(assistant): pin the rag namespace filter and entityHref id encoding
nedda76 Jun 24, 2026
3b93386
fix(assistant): stop the SQL guard from falsely rejecting valid queries
nedda76 Jun 24, 2026
6f096ee
Merge origin/main into feat/ai-assistant-impl
nedda76 Jun 24, 2026
0a10651
refactor(web): take rate-limit failClosed as an options object
nedda76 Jun 24, 2026
c493fa1
feat(assistant): add competition, trend, and regional canonical queri…
nedda76 Jun 24, 2026
d5b7247
feat(assistant): add authority→company flows query to the cookbook
nedda76 Jun 24, 2026
deeecab
feat(assistant): bound D1 rows-read per turn (Denial-of-Wallet guard)
nedda76 Jun 24, 2026
3257618
fix(assistant): handle SQLite quote-escaping in the SQL guard splitter
nedda76 Jun 24, 2026
b123090
fix(assistant): reject table-valued functions and cross-joins nested …
nedda76 Jun 24, 2026
7a65a7c
fix(assistant): harden sanitizeProse against tag reassembly and javas…
nedda76 Jun 24, 2026
a32558e
fix(assistant): scope CTE names lexically in the SQL table allowlist
nedda76 Jun 25, 2026
3fad4e4
fix(assistant): defang data:/file: URIs only in markdown link targets
nedda76 Jun 25, 2026
cd66b33
fix(assistant): validate table-column link and reject non-integer ref…
nedda76 Jun 25, 2026
6192b8e
fix(assistant): see through zero-width and entity-encoded numbers in …
nedda76 Jun 25, 2026
d5fc4c9
fix(assistant): reject duplicate output column names in the SQL guard
nedda76 Jun 25, 2026
e498b45
fix(assistant): bill D1 rows-read across auto-retries in the DoW budget
nedda76 Jun 25, 2026
c0b76f7
fix(assistant): cap per-message size and fail clearly on a missing AP…
nedda76 Jun 25, 2026
4734c04
refactor(assistant): remove dead isValidUnp helper
nedda76 Jun 25, 2026
3ed4fea
test(web): cover rateLimitRequest directly; correct BGGPT_RATE_LIMIT_…
nedda76 Jun 25, 2026
b5c37b4
fix(assistant): accept LIMIT n OFFSET m in the run_sql AST guard
nedda76 Jun 25, 2026
4e4347e
fix(assistant): server-own the report question; harden semantic_search
nedda76 Jun 25, 2026
f01109e
fix(assistant): reject non-existent calendar dates in eop_fetch
nedda76 Jun 25, 2026
5d58f63
fix(assistant): drop client system/tool messages before the model (R1)
nedda76 Jun 25, 2026
88551da
fix(assistant): fold Unicode digit forms in the E2 number gate (R1)
nedda76 Jun 25, 2026
28f3b4a
fix(assistant): block load_extension and blob-bomb functions in run_s…
nedda76 Jun 25, 2026
75ac1a3
fix(assistant): allow the scalar REPLACE() function in run_sql
nedda76 Jun 25, 2026
aaed1bb
fix(assistant): bound eop_fetch body by Content-Length before reading
nedda76 Jun 25, 2026
3a41c58
fix(assistant): use Cyrillic Т in the freshness prompt label
nedda76 Jun 25, 2026
a78a703
docs(assistant): flag count-only eop_fetch, unwired freshness, launch…
nedda76 Jun 25, 2026
92e3871
fix(assistant): close recursive-CTE, tautological-JOIN, negative-LIMI…
nedda76 Jun 25, 2026
1d66477
fix(web): collapse duplicate slashes so //assistant/chat can't bypass…
nedda76 Jun 25, 2026
8987bcc
fix(assistant): decode entities before prose defang; reject non-integ…
nedda76 Jun 25, 2026
93a2816
fix(assistant): fix two ReDoS paths and harden the report binder (ult…
nedda76 Jun 25, 2026
5232fa4
fix(assistant): keep the first row in capRows when it alone exceeds t…
nedda76 Jun 25, 2026
5a20328
fix(web): validate the chat payload shape to avoid a 500 on a public …
nedda76 Jun 25, 2026
e238308
fix(assistant): stop demanding an unwired freshness citation (ultra #7)
nedda76 Jun 25, 2026
34d7a6a
fix(assistant): encode # ? & in entityHref (ultra #13)
nedda76 Jun 25, 2026
57805da
feat(assistant): tell the model to qualify JOIN ON columns
nedda76 Jun 25, 2026
e102f00
fix(assistant): bound number-gate quantifiers to close residual ReDoS…
nedda76 Jun 25, 2026
7735788
fix(assistant): close a JOIN-ON Cartesian bypass and 4 lower-severity…
nedda76 Jun 25, 2026
b1a66e5
fix(assistant): close report value-integrity gaps in the number gate …
nedda76 Jun 25, 2026
e779f56
fix(assistant): harden the run_sql structural guard
nedda76 Jun 25, 2026
c05d973
fix(assistant): reject 3x+ self-joins (low-cardinality ~N^3 denial-of…
nedda76 Jun 25, 2026
c4bb9bb
fix(assistant): throttle every mutation method on /assistant/chat, no…
nedda76 Jun 25, 2026
4bd6b0d
fix(assistant): drop malformed chat parts before they 500 the route
nedda76 Jun 25, 2026
86d84cc
fix(assistant): keep eop_fetch reference data out of the emit_report …
nedda76 Jun 25, 2026
724958c
test(assistant): cover the rows_read x total_attempts DoW accounting
nedda76 Jun 25, 2026
d503169
fix(build): assert the fail-closed assistant rate limiter is bound at…
nedda76 Jun 25, 2026
7c83394
fix(assistant): localize the remaining /assistant/chat error responses
nedda76 Jun 25, 2026
6f02833
fix(assistant): steer the model off FTS MATCH, unsupported via run_sql
nedda76 Jun 25, 2026
f56be1e
docs(assistant): reconcile the contract specs with the code
nedda76 Jun 25, 2026
453dacd
fix(assistant): strip non-text parts from client chat messages
nedda76 Jun 25, 2026
0f5bf49
docs(assistant): make the deploy gate executable + track the renderer…
nedda76 Jun 25, 2026
cc690b7
fix(assistant): require first-party POST on /assistant/chat (CSRF → DoW)
nedda76 Jun 27, 2026
7d17281
fix(assistant): decode numeric entities to a fixpoint in the prose gate
nedda76 Jun 27, 2026
183efd5
fix(assistant): reject non-integer LIMIT literals the regex cannot clamp
nedda76 Jun 27, 2026
043d616
Merge remote-tracking branch 'origin/main' into feat/ai-assistant-impl
nedda76 Jun 27, 2026
81dd3b3
fix(assistant): move ASSISTANT_RATE_LIMITER to namespace_id 1005
nedda76 Jun 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/web/app/lib/assistant/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# AI асистент — имплементация

Наша имплементация на [`docs/spec/ai-assistant.md`](../../../../../docs/spec/ai-assistant.md),
включително хардунирането от **§9** (PR #79). Backend-ът на асистента е **опроводен от край до край в
кода**: чистите тествани модули → tool registry → agent loop → ресурс route-а `/assistant/chat`.
Остават потребителските части (dock UI, renderer на справките и `/reports/:id`, глас) и provisioning-ът
(`BGGPT_API_KEY` + bindings) — виж „Какво остава".

## Какво има (имплементирано)

| Файл | Роля | Спец. | Проверка |
| --------------------------- | -------------------------------------------------------------- | ------------ | --------- |
| `report-schema.ts` | Block речник + **сървърно обвързване на стойностите** | §4, §9.1, §7 | unit |
| `sql-guard.ts` | Read-only структурен guard + LIMIT + byte cap | §7, §9.4 | unit |
| `sql-ast-guard.ts` | AST guard: read-only + table allowlist + no-cross-join + LIMIT | §9.4 | unit |
| `describe-schema.ts` | Куриран речник на данните с капаните | §9.2 | unit |
| `rag.ts` | Vectorize + Workers AI RAG (grounding + semantic search) | _добавка_ | unit |
| `system-prompt.ts` | emit-report политика, values-by-reference, data-trust, скелет | §4/§7/§9.10 | unit |
| `tool-results.ts` | D1 редове → хендълнат `QueryResult` | §7 | unit |
| `eop-fetch.ts` | `eop_fetch` — валидация + fixed base (no SSRF) + cap | §9.7 | unit |
| `source-link.ts` | Официални линкове (ЦАИС ЕОП) за цитиране | §3 | unit |
| `emit-report-schema.ts` | Структурна валидация + model-facing JSON Schema | §4 | unit |
| `render-format.ts` | format-by-hint + entity-ref линкове | §4 | unit |
| `tools.ts` | Tool registry (SDK-агностичен) + `finalizeReport` | §2/§3 | unit |
| `agent.ts` | Vercel AI SDK glue: BgGPT през AI Gateway + `streamText` | §2/§9.5 | typecheck |
| `routes/assistant.chat.tsx` | Stateless chat ресурс route | §2/§5 | typecheck |

**Проверено:** `pnpm --filter web typecheck` → 0; **150 теста** преминават; `pnpm audit --audit-level=high`
чист; Prettier чист. Чистите модули са unit-тествани и deploy-независими; agent loop-ът и route-ът са
typecheck-проверени, но **не са runtime-проверени** (няма `BGGPT_API_KEY` / облачни bindings в тази среда).

## Ключово решение: стойностите се владеят от сървъра (§9.1)

Сърцето на интегритета. Моделът **не пише числа** — `emit_report` блоковете _референцират_ хендъли към
резултатни множества, които сървърът реално е изпълнил, а `bindReport()` пре-свързва реалните стойности.
Таблиците взимат редовете изцяло от резултата, така че моделът не може да инжектира измислен ред или да
напише „12 млрд." вместо „1,2 млрд." — векторът за клевета от
[architecture.md](../../../../../docs/architecture.md) §3. Само `text`/`callout` носят авторска проза и
са markdown-санитизирани (без raw HTML → затваря stored-XSS на публичния `/reports/:id`).

## RAG — добавка спрямо спецификацията

Спецификацията е **text→SQL агент с инструменти, БЕЗ векторно извличане.** RAG е добавен нарочно на двете
места с най-голяма полза при слаб 27B: (1) **grounding на схемата** — извлича най-релевантните trap-правила
и примерни заявки за конкретния въпрос в системния prompt (retrieval-augmented формата на §9.2); (2)
**`semantic_search`** — допълва FTS за парафрази/синоними. Пада обратно до статичния `describeSchema()`,
ако се реши, че RAG е извън v1.

## ⚠️ Provisioning gate (трябва да предхожда `wrangler deploy`)

Това PR добавя bindings към Cloudflare ресурси, които трябва да **съществуват преди deploy** — иначе
`wrangler deploy` се проваля и блокира CD за целия екип (бележка от ревюто на #80). Преди мърдж/deploy на
средата с асистента осигурете: `BGGPT_API_KEY` (secret, `wrangler secret put`), Vectorize индекс
`sigma-assistant`, R2 кофа `sigma-reports`, и еднократно индексиране на схема-корпуса (`indexSchemaCorpus`).

```bash
# Веднъж на средата, ПРЕДИ `wrangler deploy` (иначе deploy-ът пада и блокира CD на целия екип):
wrangler vectorize create sigma-assistant --dimensions=1024 --metric=cosine # ТРЯБВА 1024/cosine (bge-m3) — грешни размери чупят RAG
wrangler r2 bucket create sigma-reports
wrangler secret put BGGPT_API_KEY # интерактивно; никога не се комитва
# `AI` (Workers AI) не изисква създаване на ресурс — account capability; включи Workers AI за акаунта.
# След като индексът съществува, еднократно: indexSchemaCorpus(env.AI, env.VECTORIZE) пълни схема-корпуса.
```

Докато бекендът не е напълно осигурен, `/assistant/chat` връща контролирано **503**, а грешка по време на
streaming се показва като четим текст — не като счупена връзка или 500 (graceful degradation, §7).

**Блокиращо преди прод ключ:** route-ът изпълнява `run_sql` в момента, в който `BGGPT_API_KEY` е наличен,
а D1 binding-ът все още е read-write. Затова НЕ задавайте прод `BGGPT_API_KEY`, докато (1) `run_sql` не
работи срещу read-only D1 binding/реплика и (2) не е наложен глобален budget/circuit-breaker
(`BGGPT_RATE_LIMIT_RPM` е деклариран, но още не се чете). Двуслойният SQL guard е defense-in-depth, не
единствената бариера пред write достъп (ревю на #80). Самите две мерки остават launch-gate follow-up.

## Сигурност — затворено по ред-тийма на #80

AST table-allowlist + забрана на comma cross-join/`WITH RECURSIVE` + AST-достоверен LIMIT
(`sql-ast-guard.ts`, §9.4); guardrail **E2** (детерминистична проверка „без едри числа в прозата");
санитизация на data-cells (не само проза); fix на `eop_fetch` byte-cap-а (отказва вместо да парсва);
**per-IP rate-limit** на `/assistant/chat`; cap на история/тяло + `abortSignal` + явни
`maxRetries`/`maxOutputTokens`; entity-link id-та в bound-натите редове; + low-ове (`encodeURI` на href,
embed cap + проверка за брой, без raw D1 грешка към модела). Подробности: коментарите на #80.

**Denial-of-Wallet на `run_sql` (#122):** `LIMIT` ограничава върнатите, не сканираните редове, а D1
таксува по прочетени — затова `run_sql` натрупва `meta.rows_read` за хода и отказва по-нататъшни
заявки при надхвърляне на `D1_ROWS_READ_BUDGET` (per-ход бюджет, tunable var). raw огледалата
(`raw_*`) са изрично извън table-allowlist-а, така че неиндексираните им full-scan-ове са недостъпни.

## Какво остава

- **Фаза 2 — потребителски слой:** глобален dock (`useChat`); renderer `emit_report` → компонентите на
сайта + нов `timeseries`; `/reports/:id`, chat карти, индекс `/reports`; воден знак „AI-генерирано,
неофициално" + показан въпрос (§9.12); достъпни таблици-алтернативи за SVG блоковете (§9.6).
- **Фаза 2 — XSS бариера (gating за renderer PR-а):** markdown renderer-ът на `/reports/:id`/dock-а
ЗАДЪЛЖИТЕЛНО allowlist-ва URL схемите (`urlTransform` → само http/https/mailto) и НЕ ползва
`dangerouslySetInnerHTML` за проза/data-cells. `sanitizeProse` е само defense-in-depth и нарочно
непълна (не хваща whitespace-разделени схеми, напр. `java<TAB>script:`) — allowlist-ът е
**авторитетната** бариера (ревю #80).
- **Фаза 2 — устойчивост:** глобален budget + circuit-breaker / exponential backoff пред BgGPT
(per-IP rate-limit и graceful degradation вече са налице — остава глобалният таван).
- **Фаза 3:** глас (`/assistant/transcribe` → Whisper).
- **`semantic_search` — `ns: 'entity'` е празен** докато не се добави entity indexer (ETL pipeline,
Фаза 2). Инструментът е регистриран и работи, но ще връща 0 попадения за всяко запитване, докато
pipeline-ът не напълни Vectorize с имена на компании/договори/възложители.
- **`eop_fetch` връща само БРОЙ редове на ден, не самите данни** (днес): инструментът сваля, капва и
парсва файла, но връща „N реда" и не пуска `QueryResult` в `ctx.results`, така че моделът НЕ може да
обвърже EOP стойност в `emit_report`. Засега е probe за наличие/свежест, не източник на данни (ревю #80).
- **Freshness не е свързан:** route-ът извиква `runAssistant` без `freshness`, така че редът за свежест в
системния prompt не се появява. Да се подаде `data_freshness` (по източник) — follow-up, не в това PR.
- **Втвърдяване:** read-only D1 data path + неотменяем per-query timeout за `run_sql` (§9.4 — AST guard-ът
и allowlist-ът вече са налице); HMAC-подпис на сървърните съобщения (§9.3); memoize
`(sql_hash, freshness)` + дедуп на справки (§9.8); golden-report CI, вкл. adversarial prompt-injection
(§9.9); launch gate (Turnstile).
25 changes: 25 additions & 0 deletions apps/web/app/lib/assistant/agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { resolveMaxSteps } from './agent';

describe('resolveMaxSteps', () => {
it('uses the default for a missing or non-numeric value', () => {
expect(resolveMaxSteps(undefined)).toBe(6);
expect(resolveMaxSteps('')).toBe(6);
expect(resolveMaxSteps('abc')).toBe(6);
});

it('falls back to the default for 0 or a negative value (never stalls the loop)', () => {
expect(resolveMaxSteps('0')).toBe(6);
expect(resolveMaxSteps('-4')).toBe(6);
});

it('clamps an over-large value to the hard ceiling (never uncaps BgGPT calls)', () => {
expect(resolveMaxSteps('9999')).toBe(20);
});

it('passes a sane in-range value through (flooring fractions)', () => {
expect(resolveMaxSteps('3')).toBe(3);
expect(resolveMaxSteps('20')).toBe(20);
expect(resolveMaxSteps('4.9')).toBe(4);
});
});
120 changes: 120 additions & 0 deletions apps/web/app/lib/assistant/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Thin Vercel-AI-SDK wiring (spec §2). Carries NO logic — it maps the SDK-agnostic tool registry
// (tools.ts) to SDK `tool()`s and runs the streamed tool-calling loop against BgGPT, routed through
// the Cloudflare AI Gateway (§9.5). Everything testable lives in the pure modules; this layer needs
// `BGGPT_API_KEY` + bindings and is only exercised end-to-end on a deployed Worker.

import { createOpenAI } from '@ai-sdk/openai';
import {
convertToModelMessages,
jsonSchema,
stepCountIs,
streamText,
tool,
type ToolSet,
type UIMessage,
} from 'ai';
import { buildSystemPrompt } from './system-prompt';
import { EMIT_REPORT_JSON_SCHEMA } from './emit-report-schema';
import { ASSISTANT_TOOLS, finalizeReport, type ToolContext } from './tools';

export interface AgentEnv {
BGGPT_API_KEY: string;
AI_GATEWAY_BASE_URL?: string; // OpenAI-compatible AI Gateway passthrough; empty → api.bggpt.ai (§9.5)
BGGPT_MODEL?: string;
MAX_STEPS?: string;
}

const DEFAULT_MODEL = 'bggpt-gemma-3-27b-fp8';
const DEFAULT_BASE_URL = 'https://api.bggpt.ai/v1';
const DEFAULT_MAX_STEPS = 6;
// Hard ceiling on the tool-loop length regardless of env, bounding worst-case BgGPT calls per turn.
// `MAX_STEPS` is operator-supplied config — a misconfigured deploy could otherwise stall the loop
// (0/negative) or uncap it (a huge value). (review #80)
const MAX_STEPS_CAP = 20;

/**
* Resolve the tool-loop step budget from the (untrusted) env string: fall back to the default on a
* missing / non-numeric / < 1 value, and clamp to [1, MAX_STEPS_CAP].
*/
export function resolveMaxSteps(raw: string | undefined): number {
const n = Number(raw);
if (!Number.isFinite(n) || n < 1) return DEFAULT_MAX_STEPS;
return Math.min(Math.floor(n), MAX_STEPS_CAP);
}

// `.chat()` forces the chat-completions endpoint BgGPT speaks (not the OpenAI Responses API).
function buildModel(env: AgentEnv) {
const provider = createOpenAI({
baseURL: env.AI_GATEWAY_BASE_URL || DEFAULT_BASE_URL,
apiKey: env.BGGPT_API_KEY,
});
return provider.chat(env.BGGPT_MODEL || DEFAULT_MODEL);
}

function buildToolSet(ctx: ToolContext): ToolSet {
const set: ToolSet = {};
for (const t of ASSISTANT_TOOLS) {
set[t.name] = tool({
description: t.description,
inputSchema: jsonSchema(t.parameters as unknown as Parameters<typeof jsonSchema>[0]),
execute: async (input: unknown) => t.execute((input ?? {}) as Record<string, unknown>, ctx),
});
}
// Terminal tool — finalizes the report by binding values from THIS turn's server-executed results
// (never client-supplied). Returns validation errors for the model to retry against (§4, §9.1).
set.emit_report = tool({
description:
'Финализира справка. Блоковете реферират резултатни хендъли (R1…); сървърът свързва числата. ' +
'Извикай го за всеки отговор с число, класация, сравнение или разбивка (виж системните правила).',
inputSchema: jsonSchema(EMIT_REPORT_JSON_SCHEMA as unknown as Parameters<typeof jsonSchema>[0]),
execute: async (input: unknown) => {
const r = finalizeReport(input, ctx);
return r.ok
? { ok: true as const, report: r.report }
: { ok: false as const, errors: r.errors };
},
});
return set;
}

export interface RunAssistantOptions {
env: AgentEnv;
ctx: ToolContext;
messages: UIMessage[];
schemaContext?: string[];
freshness?: string;
abortSignal?: AbortSignal; // wire `request.signal` so a disconnect cancels the BgGPT loop (review #80)
}

/**
* Run one assistant turn: BgGPT (via AI Gateway) + the bounded tool loop, returned as the streamed
* UI-message Response the chat route hands back to the dock. (Returns a `Response` rather than the
* SDK result so no internal SDK type leaks across the module boundary.)
*/
export async function runAssistant(opts: RunAssistantOptions): Promise<Response> {
const maxSteps = resolveMaxSteps(opts.env.MAX_STEPS);
const messages = await convertToModelMessages(opts.messages);
const result = streamText({
model: buildModel(opts.env),
system: buildSystemPrompt({ schemaContext: opts.schemaContext, freshness: opts.freshness }),
messages,
tools: buildToolSet(opts.ctx),
stopWhen: stepCountIs(maxSteps),
// Bound worst-case resource use (review #80): cancel on client disconnect; one explicit retry
// (the SDK default of 2 silently multiplies the per-step call count beyond the visible step cap);
// a per-step output backstop (the model emits block structure + refs, not the bound data values).
abortSignal: opts.abortSignal,
maxRetries: 1,
maxOutputTokens: 4096,
});
return result.toUIMessageStreamResponse({
// Graceful degradation (§7): a BgGPT outage / rate-limit / timeout surfaces mid-stream as a
// readable Bulgarian line instead of a broken connection. The SDK default redacts the error to
// "An error occurred." to avoid leaking server details — we log it server-side (Workers tail)
// and show our own message. A full rate-limit + circuit-breaker is the launch gate (README).
onError: (error) => {
console.error('[assistant] stream error', error);
return 'Асистентът временно не е достъпен. Опитай отново след малко.';
},
});
}
Loading
Loading