Replace the single-scalar cost model on AgentRun with an append-only
UsageFact ledger. One AgentRun owns zero or more UsageFact rows; each
records one billable consumption event (model completion, external
capability, or tool proxy) with its own provider/model/tokens/quantity/
cost. AgentRun.costUsd/inputTokens/outputTokens become a derived rollup
cache.
This unblocks external capabilities (PDF->MD bundle, audio/video->text)
that bill in non-token units (pages, seconds) through a different
provider than the main agent loop, without per-capability schema changes
or nested AgentRuns (which would pollute lock/admission/session
semantics).
Contract:
- spec/Spec/System/Agent/Usage.lean pins UsageFact, UsageFactKind,
CostSource and three invariants: append-only; belongs to one run,
never holds a lock; missing cost != zero (ADR-0022).
- ADR-0026 records the decision, the rejected nested-Run alternative,
the rollup cache strategy, and the deferred capability registry /
pricebook / post-hoc correction flows.
Schema:
- UsageFact model with indexes on (runId, occurredAt), (runId, kind),
(provider, model, occurredAt), (capabilityId, occurredAt).
- Migration backfills one synthetic model_completion fact per existing
run with recorded cost/tokens (correlationId = runId marks backfill);
truly unrecorded runs stay runsWithoutCost per ADR-0022.
Write path (trigger finish):
- Write the UsageFact first, then mirror it onto AgentRun as two
separate statements (not one transaction). The fact is the truth so it
goes first; the cache is derived so it goes second. A crash between
them leaves the cache stale but the usage service re-reads facts
directly, so this is recoverable; the reverse order would lose the
truth. Separate statements also avoid an AgentRun row lock held across
the insert's FK ShareLock, which deadlocked concurrent workspace
teardown under the Organization->Project->AgentRun->UsageFact cascade.
Read paths:
- org/usage.ts aggregates from UsageFact, ignoring the AgentRun cache.
- slash /usage buckets by (fact.provider, fact.model); a run with a
main loop + an external call lands in two buckets.
- session detail exposes usageFacts[] + costSource for future per-run
cost-breakdown UI.
Tests:
- usage.test.ts: 6 integration tests pin fact aggregation, missing-cost-
!=-zero, multi-fact-per-run, empty-run, project-level, empty-org.
- trigger.test.ts: existing /usage assertion ($0.0023,
openrouter / mock-model) passes on the fact path.
- feishu-reactions mock prisma gains usageFact.create.
Drop markdown_to_pdf MCP surface, implementation, tests, and md-to-pdf dependency.
Roles that still list markdown_to_pdf must be cleaned before startup.
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
Teachers need ad-hoc Markdown → PDF. Ship an MCP tool powered by
md-to-pdf (Marked + headless Chrome) so remote images/CSS work, with
workspace-scoped basedir, front-matter stripped so untrusted markdown
cannot override dest/basedir/launch options, and MathJax for $/$ math.
Also include WebFetch and WebSearch in the unrestricted role tool
surface by default. Deploy skips Puppeteer's browser download and
expects a host Chrome/Chromium (PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
The admin role model picker was hardcoded to the env-default model registry
(createDefaultModelRegistry), which only ever returned a single Sonnet model.
Roles could not select any other model regardless of what the org's provider
connection supported.
Replace the env-only model list with a ProviderModelCatalog that:
- Resolves the org's ACTIVE provider connection credential (BYOK or
platform-managed, encrypted via ADR-0024 envelope)
- Calls OpenRouter GET /v1/models?supported_parameters=tools to list
tool-capable models available to that org
- Caches results in-memory with a 5-minute TTL per organization
- Falls back to the env-default registry when no ACTIVE provider exists
The runtime modelRegistry no longer validates role.defaultModel against the
env model list — the admin already validated by selection from the provider
catalog. The env list remains as the fallback for roles with null defaultModel.
The admin roles page loads models independently (non-blocking) so roles
remain editable even if the provider API is slow or unreachable.
Add full skill lifecycle to the org-admin web surface: create, read,
edit, disable. Skills are directories (SKILL.md manifest + supporting
files), content-addressed by SHA-256 in an immutable store.
Backend:
- skillStore: extract commitSkillContent (shared populate→inspect→
dedup→atomic rename); add importSkillFromFiles (in-memory file list
ingestion) and readSkillFiles (read stored version back as UTF-8)
- configuration: add installSkillFromFiles, readSkillFiles, disableSkill
(soft-delete + archive bound role sessions), updateSkillDescription
(label-only, no archival); refactor installSkill to share
commitInstalledSkill
- agentConfigRoutes: wire skillStoreRoot; add GET
/agent-skills/:name/files, PUT /agent-skills/:name (create/replace),
PATCH /agent-skills/:name (description/disable)
- orgRoutes: pass readSkillStoreRoot() to agent config routes
Frontend:
- api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods
- SkillEditor.svelte: file tree + text editor + version/description form
- skills/+page.svelte: skill list, create form (generates SKILL.md
template), per-skill editor
- layout: add 技能 nav item
ADR-0018: update Decision to reflect web surface joining host-console
CLI in the shared content-addressed ingestion pipeline.
Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web
installation is one implementation of it.
Add a ⛔ 中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.
- builder.ts: render interrupt action (danger button + confirm) while the
run is live; new `interrupted` flag renders a 已中断 footer instead of
完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
resolves the interrupt button, authorizes agent.cancel, aborts the run.
Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
(MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
"谁能取消"). Default role remains MANAGE (Capability.normalCancel).
Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
- parsePostMessage: full Feishu post payload parser with locale fallback,
supports text/a/at/img/media/file/code_block/br/hr/emotion elements
- Trigger accepts post messages, extracts text + downloads attachments
- Post messages with attachments bypass batching, text-only posts batch
- Add unit tests (10) for all element types and edge cases
- Increase max message length from 4000 to 8000 chars
- Add splitAtBoundary: paragraph > newline > space priority, never split
inside fenced code blocks (move split to before opening fence)
- Add sendLongText: sends multi-chunk long messages sequentially
- Update PatchableTextStream.flush to use splitAtBoundary
- Add unit tests (6) for splitting edge cases