Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

### Added

Tier 2 — http. Tier 4 — ai.

- **Provider prompt caching: an `agent()` no longer pays input price for its stable prefix on every turn.** Every turn re-sends the whole transcript, and nothing asked a provider to cache it. A `GenerateRequest` now takes `system: { stable, run }` (two blocks; the stable one first and byte-identical across runs) and `promptCache: { session? }`, and each provider marks the stable system part and the transcript's tail the way its wire and host want — `PROMPT_CACHE_RULES` in `@ultimat3/ai`, a table, sources quoted in `prompt-cache.ts`: `cache_control: { type: 'ephemeral' }` on Anthropic content blocks (three of the four breakpoints), the same mark on content parts for an `anthropic/…` model on OpenRouter (two), and **no** mark for any other model there or on OpenAI, where caching is implicit and an explicit mark on Gemini is a paid write. A session key travels as OpenRouter's `x-session-id` header or OpenAI's `prompt_cache_key`; an endpoint the table does not name is sent nothing. `agent({ system, promptCache })` and `llm({ promptCache })` are the declaration's side: `'auto' | 'off' | { session }`, and `system()` returns the text loaded at run time as `{ stable?, run? }`.
- **Default: `agent()` caches (`promptCache: 'auto'`), `llm()` does not (`'off'`).** This is the one behaviour that changes for a caller who passes nothing: an agent's requests to an Anthropic model now carry `cache_control` marks (so `system` is sent as blocks or parts, not a string), a first turn is billed as a cache write (1.25x input on Anthropic) and later turns as reads, and the pre-flight cost estimate prices the prompt as a write **where a provider serving the model would mark the request** (`Provider.marksPromptCache`), at the dearer of the write and input rates — so a `budget.costPerCall` set within 25% of an agent's prompt cost now refuses for an Anthropic model, and is unchanged for Gemini, OpenAI and any endpoint the table does not name. `promptCache: 'off'` restores the old request byte for byte. A one-shot `llm()` is unchanged. A mark is never put on an empty or whitespace-only text block, and marks a raw request already carries count against the vendor's four first. A session key over 64 characters is sent to OpenAI as a hash of itself; OpenRouter's header takes up to 256 and refuses more.
- **Cache usage is reported per turn and per run.** The chat-completions reader takes `prompt_tokens_details.cache_write_tokens` as well as `cached_tokens` out of `prompt_tokens` (which includes both) and reports them as `cacheWriteTokens` / `cacheReadTokens`; it reported a write as full-price input before. `onTurn`'s event gains `run` — the running `usage` and `cost`, priced once from the summed usage — and the `agent.turn` span event gains `llm.cache.read_tokens` / `llm.cache.write_tokens`. Prices are the model row's existing `cacheReadPerMillion` / `cacheWritePerMillion`; a token budget counts read and written tokens one for one, as it always has.
- **`maxRepeatedToolCalls` — an agent that repeats itself is stopped (`X_AGENT_TOOL_LOOP`, 500).** A cycle of one to three tool turns — the same calls, arguments, results and failures — gone round that many times ends the run: the same turn over and over, or A,B,A,B…. **Default 3, on for every agent**: a loop that used to end in `X_AGENT_MAX_TURNS` or `X_AI_BUDGET_EXCEEDED` after repeating itself now ends earlier with this code. A polled tool whose answer changes is not a repeat; a repair turn neither counts nor resets. Registered `terminal`, so an `agentJob()` dead-letters it on the first attempt.
- **`deadlineMs` — a wall-clock ceiling for one agent run (`X_AGENT_DEADLINE`, 504).** No default. Passing it aborts the model call in flight on its socket, fires every running tool's own `ctx.signal`, and starts no further turn. A tool that ignores its signal is no longer waited for but still runs to its end. Registered `terminal` for jobs.
- `@ultimat3/ai` exports `PROMPT_CACHE_RULES`, `promptCacheRule`, `systemText`, `SESSION_KEY_MAX`, `OPENAI_CACHE_KEY_MAX`, `AgentToolLoopError`, `AgentDeadlineError` and the types `SystemPrompt`, `SystemParts`, `PromptCacheHint`, `PromptCacheOption`, `PromptCacheRule`, `CacheWire`, `AgentSystem`, `AgentRunUsage`.
Tier 0 — core. Tier 1 — seo. Tier 4 — render. Tier 5 — cli.

- **`seo.sitemap.sections` — a sitemap index with named, paginated sections, by configuration.** `/sitemap.xml` was one `<urlset>` until a site passed 50,000 URLs. `seo: { sitemap: { pageSize: 1000, sections: [{ name: 'blog', match: '/blog/**' }, { name: 'ciudades', match: '/juzgados/**' }] } }` makes it a `<sitemapindex>` of `/sitemaps/pages-1.xml`, `/sitemaps/blog-1.xml`, `/sitemaps/blog-2.xml`, …, each a valid urlset; a page no section matches goes to the reserved `pages`. `match` is a path pattern or a list (`/precios`, `/blog/*`, `/blog/**`), read against the path without its locale prefix, so a page and its locale copies share a file; the first section that matches wins. Inside a section pages are sorted by path — the bytes depend on which pages exist, not on the order a query returned them — files are numbered from 1, a section with nothing in it writes no file, and the index `<lastmod>` of a child is the newest `<lastmod>` inside it. The web role serves every child from the route it already had (`GET /sitemaps/:file`, `application/xml`, `public, max-age=3600`, from the same hourly answer as the index — a refresh that fails keeps the previous index and its children together), the static export writes the same files, and `robots.txt` still names `/sitemap.xml` alone. A `name` that is not a lower-case slug, a duplicate, `pages`, a `match` that is not a path pattern or a `pageSize` outside 1–50,000 is `X_CONFIG_INVALID` at boot. **With no `sections` the output is byte-for-byte what it was** (`sitemap-sections.test.ts` pins the hash). [SEO → Sections](https://github.com/developerz-ai/ultimate/wiki/SEO#sections-an-index-by-configuration).
Expand All @@ -19,6 +27,9 @@ Tier 0 — core. Tier 1 — seo. Tier 4 — render. Tier 5 — cli.

### Fixed

Tier 4 — ai.

- **A caller that goes away during an agent's model call is `X_ABORTED`.** The aborted socket surfaced as `X_AI_PROVIDER_UNAVAILABLE`, which the package classifies `retryable`, so an HTTP client was told to come back for a run it had itself abandoned. `X_ABORTED` is core's and unclassified: it reads `terminal` on the wire, and in a job the attempt count still decides — nothing about a drain or an attempt timeout changes, jobs settles those off its own signal.
Tier 1 — seo. Tier 5 — cli.

- **A page that is `noindex` in one locale is no longer listed — or cross-linked — in that locale.** The sitemap read each route's `meta` once, in no locale, and emitted one `<url>` per routed locale: a page whose `meta` answers `robots: { index: false }` for English only was listed at `/en/…`, and every other copy's hreflang pointed at it ("alternate page with noindex"). `meta` is now read once per routed locale; the page is listed in the locales that may be indexed and its cluster names only those. As before, this reads a route with no params and no `load` — a dynamic page says it with `locales` on its `prerender()` entry.
Expand Down
49 changes: 49 additions & 0 deletions docs/history/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,3 +695,52 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts

- Metadata is bound `::text::jsonb`. A bound string cast straight to `::jsonb` is JSON-encoded
twice, reads back correctly, and makes every `metadata ->> key` filter match nothing.

## Provider prompt caching, and the unattended-run guards (2026-10)

- **Why the loop marks the cache and an app does not.** Every turn of an `agent()` re-sends the
whole transcript, so a 10-turn run with an 8–20k-token stable prefix paid input price for that
prefix ten times. `openai-wire.ts` already READ `cached_tokens`; nothing sent a breakpoint. The
mark has to be placed by whoever builds the request, and that is the loop.
- **Why a rule table (`PROMPT_CACHE_RULES`) and not a flag on the provider.** The same
chat-completions wire needs three different things: an explicit mark for an Anthropic model on
OpenRouter, NO mark for Gemini there (an explicit one turns the free implicit cache into a paid
write), and nothing an unknown self-hosted server might reject. Host and model prefix decide,
first match wins, unknown gets nothing.
- **Why `agent()` defaults on and `llm()` off.** A write costs 1.25x input on Anthropic and a read
0.1x or less: two turns over a prefix P cost 1.35P marked against 2P unmarked, and a tool loop is
two turns or more by construction. A one-shot `llm()` has no second request to read the cache.
- **Why the stable part is the FIRST system block and the only system block marked.** It is the
half other runs share; the per-run half after it is covered by the transcript's own tail mark.
- **Why `prompt_tokens` has both counters subtracted.** Measured against OpenRouter on 2026-10-11:
turn one reported `prompt_tokens: 19918` with `cache_write_tokens: 19914`, turn two
`prompt_tokens: 19988` with `cached_tokens: 19914` and `cache_write_tokens: 72`.
- **Why the pre-flight estimate prices a hinted prompt as a write.** It is the dearest the prompt
can be billed (turn one), and a ceiling checked against a cheaper guess is one a real call walks
through.
- **Why the repeated-turn guard compares the RESULT too.** Polling one tool with one argument is
legitimate while its answer changes. The same call with the same answer cannot be followed by a
different turn. Three ceiling tests that script a model repeating itself for ever lift the guard
(`maxRepeatedToolCalls: 100`) so the ceiling they are about is the one that stops the run.
- **Why `deadlineMs` has no default.** `GenerateRequest.signal`'s own rule: a timeout nobody
declared is a lost answer nothing reports.
- **Why a caller abort mid-call is rethrown as `X_ABORTED`.** The aborted socket surfaced as
`X_AI_PROVIDER_UNAVAILABLE`, the one code this package classifies retryable, so a job retried a
run its caller had abandoned.
- Condensed out of `packages/ai/CLAUDE.md` in the same change, to stay under its ceiling: the
default `BudgetStore` grew by one entry per caller when every call wrote its keys (hence
`takeScope` / `meteredKeys`); budget keys are absent for an `undefined`, `null` or `''` org, the
`orgless` case `query`'s cache uses; `RetryPolicy` / `DEFAULT_GATEWAY_RETRY` keep the field names
an app writes; the status tables and the OpenAI `estimatedUsage` fallback are the differences
`provider-parity.test.ts` asserts between the two wire formats.
- **Review follow-ups (same change, second commit).** The two agent stops are registered
`terminal` because an unclassified code keeps a job's attempt count: an `agentJob` with
`attempts: 3` bought a repeating run three times. `X_ABORTED` was deliberately NOT classified
in core: jobs raises and reads that code itself (a fenced step write, an attempt timeout), and
a registered `terminal` would dead-letter those on attempt one. The write-rate estimate is
gated on `Provider.marksPromptCache` because `estimateCost` cannot see a host, and an estimate
that rose 25% for a Gemini agent refused calls a tight `costPerCall` had allowed. The loop
guard looks for cycles of up to three turns because A,B,A,B survived fifty turns under a
consecutive-only comparison. OpenAI's `prompt_cache_key` maximum could not be confirmed (its
guide and SDK types state none; the API reference answered 403), so a long key is hashed, not
refused: a refusal on an unverified limit would fail calls the endpoint may accept.
12 changes: 11 additions & 1 deletion framework.manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading