mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 09:24:43 +08:00
* ci: run CI for the owner identity seam integration branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(auth): owner identity seam, phase 1 — pluggable authenticator with today's behavior * feat(auth): add the owner identity seam with built-in authenticators Introduce lib/server/identity/: an OwnerAuthenticator contract (SubjectKind, OwnerPrincipal, AuthOutcome), a single-shot, boot-validated configureOwnerAuthenticator() registry, and the anonymousCookie and sharedTeam built-ins that reproduce today's owner resolution exactly (same anonymous_id cookie, owner id strings, statuses and env semantics). Every owner-scoped route handler and the workspace Server Action now resolve their owner through one memoized per-request resolution instead of the three previous copies (agent-runtime/owner.ts, with-owner.ts and the Server Action re-implementation). Publish and unpublish decide from the principal's course:publish role instead of an anon: id prefix. An invalid credential from an authenticator is answered with 401 on every surface and never falls back to an anonymous owner. A source scan keeps the anonymous owner cookie and anon: prefix checks out of code outside the identity module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(auth): describe the owner identity seam and host registration Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): tighten the identity boundary guard and Server Action cookies - Scan workspace package sources too, flag imports of the concrete built-in authenticators outside lib/server/identity, and police the slice/substring/indexOf/includes spellings of an anon: id-shape check. Stop re-exporting the built-ins from the host-facing index. - Refuse setCookies returned by authenticateFromContext, as the authenticate() fallback already did, and document that it must write cookies itself through next/headers. - Let the route-test owner stub carry explicit kind/roles, and cover the 403 forbidden branch of publish and unpublish for a signed-in owner without course:publish. - README: registration conflicts fail the boot; an invalid principal is rejected per request with a 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(auth): owner identity seam, phase 2 — runtime and assets keyed by the owner * feat(auth): owner identity seam, phase 2 — runtime and assets keyed by the owner Runtime sessions and assets on /api/persistence now take their identity from the same memoized owner resolution as documents, instead of a client-chosen x-learner-key behind a development bearer token that shipped in the public bundle. - Runtime: the storage handler's learner key is principal.ownerId. A path or body naming another learner answers 403 FORBIDDEN_LEARNER and another learner's session answers 404, whatever x-learner-key says; the header is no longer read. The browser learns its key from the new GET /api/persistence/learner-key and sends no credential of its own. The Pi whiteboard routes derive the same key from the owner. PERSISTENCE_DEV_TOKEN, NEXT_PUBLIC_PERSISTENCE_TOKEN and PERSISTENCE_ALLOW_INSECURE_DEV_AUTH are gone from the server path and the client, and server-auth.ts is deleted. Learner merge stays refused. - Assets: allocations land in a per-owner partition (owner:<ownerId>), so quota is per owner and replace/delete reach only the owner's entries. Reads stay capability-by-id where a course viewer needs them: another owner's committed entry is readable while a live course references it. Legacy entries in the old shared partition stay readable by id, and may be replaced or deleted only by an owner who owns every course referencing them. Server-generated media is stored under the run owner's partition; asset-id extraction and vision image resolution use the same rule. - Tombstones: runtime of a deleted course reads as absent (404, empty lists) and takes no new sessions or records, through a guard in the app's runtime composition; no package schema change. The identity boundary scan now also fails on any source that reads x-learner-key or the retired development token variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(persistence): describe owner-keyed runtime and assets, drop the dev token Remove the development persistence token from the server-persistence recipe, .env.example, the Docker build arguments and the deployment docs; describe runtime learner keys, per-owner asset partitions and quota, the legacy shared-partition rule and the tombstone behavior; and record the breaking change for existing server-persistence runtime data under Unreleased. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(storage): scope asset references to principals, typed stage refusal, uniform create collision @openmaic/storage 0.32.0. - PgDocumentStore takes assetReferencePrincipals: with reference tracking on, a write references and commits only entries held by the listed principals. An id naming another principal's entry records no row and changes no lifecycle column, like an unknown id, and the write is never refused. Without the option any held entry is referenced, as before. AssetCollector takes a matching per-document-owner function so the one-time backfill scopes each document the same way. This keeps a document naming a leaked id from committing, exposing or pinning another owner's allocation. - RuntimeStageNotFoundError: a store may refuse createSession for a stage its host considers absent; the runtime handler answers 404 STAGE_NOT_FOUND, recognizing the error by class or by code. - A session create over a taken id answers 409 SESSION_ALREADY_EXISTS whoever holds it. It used to answer 403 for another learner's session and 409 for one's own, which told a caller whose session an id was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): close owner-seam boundary gaps in references, tombstones and legacy assets - Document writes reference and commit only the writer's own asset entries and legacy shared ones (owner-bound document store and the collector backfill), so another owner's course naming a pending id can no longer commit, expose or pin it. - The foreign-read rule additionally requires the referencing live course to belong to the entry's owner, as defense in depth for reference rows that did not come from a scoped write. - The tombstone refusal for new runtime sessions moved from a raw path comparison in the route into the guarded store's createSession (RuntimeStageNotFoundError, 404 STAGE_NOT_FOUND), so no encoded spelling of the path routes around it. - Replacing or deleting a legacy shared entry checks reference ownership and mutates in one transaction under the entry row lock, which a concurrent reference insert must wait for. - Tests build the uncommitted-but-referenced, tombstoned-with-rows and foreign-reference states directly so each half of the read rule is observable, and a PostgreSQL suite holds a competing reference open across a legacy delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(persistence): upgrade note for dev-token-gated deployments, owner-scoped references Tell operators whose only access gate was the development token to put the deployment behind an access code or gateway, register an owner authenticator, or turn server persistence off before upgrading, and describe that a course references only its owner's media. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(storage): per-owner reference principals and a typed taken-id conflict - PgDocumentStore's assetReferencePrincipals is now a function of the store's bound owner, evaluated per write, instead of a fixed list. A store re-bound with forOwner therefore scopes to the new owner rather than carrying the previous owner's principals onto its writes. It has the same shape as AssetCollector's backfill option, so one function serves both. - PgRuntimeStore and BrowserRuntimeStore raise RuntimeSessionExistsError for a taken session id, and the runtime handler classifies it as 409 SESSION_ALREADY_EXISTS even when its re-read cannot see the holder (for example a session a host-side wrapper hides). Recognized by class or by code. Still the unreleased 0.32.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): guard the Pi whiteboard runtime and answer hidden collisions with 409 - Every server-side runtime writer now uses the tombstone-guarded store: the Pi native whiteboard service gets it through the same helper as /api/persistence/runtime/*, so a deleted course takes no new whiteboard runtime either. - Creating a session whose id is held by a session a tombstone hides answers the uniform 409 SESSION_ALREADY_EXISTS instead of a 500, covered on PGlite and on real PostgreSQL. - The owner-bound document store passes its reference-principal function, so the scoping follows the bound owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(auth): owner identity seam, phase 3 — accounts through an identity gateway (trustedProxyHeader) * feat(auth): owner identity seam, phase 3 — trusted-proxy-header authenticator Add the trustedProxyHeader built-in: real accounts through an identity gateway (oauth2-proxy, Authelia, a Keycloak-based proxy, an institutional reverse proxy) that signs the user in and forwards the verified user in a request header. OpenMAIC never handles passwords or tokens. - Selected with OWNER_AUTHENTICATOR=trusted-proxy and validated in instrumentation register(). TRUSTED_PROXY_SECRET (32-1024 printable ASCII characters) is required; header names default to x-openmaic-proxy-secret and x-forwarded-user, and an optional groups header with TRUSTED_PROXY_ADMIN_GROUPS grants admin. An unknown selector, a TRUSTED_PROXY_* variable without the selector, a shared owner id or a host-registered authenticator alongside it, and malformed, reserved or repeated header names fail the boot. - Trust boundary: Next.js exposes no TCP peer address to route handlers, middleware or Server Actions, and fills x-forwarded-for from the socket only when the client sent none, so no address allowlist is offered. The gateway secret is compared in constant time (hashed, timingSafeEqual). - Per request: a missing or wrong secret, a missing or blank user, a user header with a comma (duplicate lines arrive comma-joined), or a user whose proxy:<user> id fails the owner id guard is 401 INVALID_CREDENTIAL, never an anonymous owner. The guard failure is a 401 rather than a 500 because the value comes with the request. The user is trimmed and keeps its case. Every gateway user holds course:publish; kind user, assurance verified, channel proxy; no cookie. Route handlers and Server Actions share one code path. - The unset-ACCESS_CODE boot warning is skipped in this mode; the gateway is the access gate and ACCESS_CODE stays independent. - The identity boundary scan now also fails on gateway identity headers (x-forwarded-user/-groups/-email, x-auth-request-*, remote-user, the secret header) and TRUSTED_PROXY_* read outside lib/server/identity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(auth): document accounts through an identity gateway Describe the trustedProxyHeader built-in in the README owner identity section (and its Chinese counterpart): configuration, request semantics, the trust boundary and why it is a shared secret, the interaction with ACCESS_CODE, boot-time validation, and an oauth2-proxy example that injects the user, groups and secret headers. Add the .env.example block, a SECURITY.md note and an Unreleased changelog entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): harden the trusted-proxy authenticator after review - Reserve the header names HTTP, Next.js and forwarding proxies set themselves: configuring the user, groups or secret header to rsc, next-action, forwarded, x-forwarded-for/-host/-proto/-port, x-real-ip, or anything starting with x-middleware-, x-invoke-, x-nextjs- or next-router- now fails the boot. - Log a one-time boot warning when TRUSTED_PROXY_ADMIN_GROUPS is set: the admin role is granted from the groups header, which is trusted on the strength of the shared secret alone, so the gateway must overwrite or strip it. - POST /api/chat/pi resolves the owner up front and answers 401 when the authenticator rejects the credential, like every other owner-resolving route, instead of continuing without an owner. The anonymous default always resolves, so it is unaffected. - The identity boundary scan also covers the Authentik, Azure App Service authentication, WebAuth, Cloudflare Access, Google IAP and AWS ALB OIDC header families. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(auth): admin-groups warning and a corrected oauth2-proxy example - Warn next to TRUSTED_PROXY_ADMIN_GROUPS (README, README-zh, .env.example) that the groups header is trusted on the strength of the shared secret alone, and list the reserved header names. - The oauth2-proxy example now states it targets v7.14+ (nested claimSource/secretSource), uses the canonical bindAddress, sets preserveRequestValue: false on all three headers and insecureSkipNonce: false, forwards the subject with `claim: user`, and notes that the secret variable holds the literal secret. It adds the legacy-option migration caveat, a warning not to exempt app routes via skip-auth or trusted-ip options, and notes that the IdP must release the groups claim and group names must not contain commas. - Changelog: the new boot checks and the /api/chat/pi 401. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence): owner identity seam, phase 4 — host extension hooks * feat(persistence): owner identity seam, phase 4 — host extension hooks Let a host add product behavior at four points without forking a route, registered once from instrumentation.ts register() like the owner authenticator and sealed on first use. Nothing changes when no hook is registered. - configurePersistenceHooks({ name, authorizeCreate, onCreate, library, beforeAssetAllocate }): - authorizeCreate / onCreate run once per created course, inside the create transaction, in the transaction that inserts the course's ownership row. Saves of a course the owner already holds, and a create that loses a race to a concurrent create of the same id, are updates and run no create hook. A refusal answers 403 CREATE_REFUSED; a throw rolls the course, its ownership row and the host's writes back together. The hooks receive the request principal, or only the owner id for a background agent run. - library chooses which stage ids GET /api/stages lists; the route builds the usual summaries in the provider's order and drops every id the read path would refuse (deleted or unclaimed). folderId is shown only on the principal's own courses. - beforeAssetAllocate runs inside the storage handler's asset authorization step for POST /assets, after the owner is resolved and before the body is read; a returned Response is sent instead, with nothing stored and no quota counted. - configureAssetByteStore({ name, create, signsReadUrls }) replaces the ASSET_S3_BUCKET switch for both the persistence provider and the asset collector. A host store must keep bytes outside the registry database. Under ASSET_BYTE_EGRESS=redirect a store that does not declare signsReadUrls stops the server at boot; the built-in layers keep their behavior. - @openmaic/storage 0.33.0: DocumentWriteRefusedError lets a store refuse a write as policy; the document HTTP handler answers 403 with its code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): harden the host extension hooks after review Correctness: - Registration reads every hook once and stores it bound to the object the host passed, so hooks defined on a class prototype or as non-enumerable properties are kept. A spread copy dropped them silently, including the authorizeCreate and beforeAssetAllocate gates. Same for configureAssetByteStore. The unknown-key check now applies to plain objects only, since class instances carry their own state fields. - The owner-bound document store carries each call's operation in its own async context (AsyncLocalStorage) instead of a field on the instance. One store is shared by every tool of an agent run; a concurrent call could overwrite the field before the transaction read it, so a create was gated as a read (no ownership row, no create hooks) or claimed another stage. create_stage also runs sequentially within a tool batch. Hardening: - isDocumentWriteRefusedError recognizes a refusal from another copy of the storage package by name and shape (not by code alone). The document handler and POST /api/stages use it. - beforeAssetAllocate also gates PUT /assets/{id}/content. The request carries operation ('create' | 'replace') and the decoded assetId from the handler's own routing. - Agent runs: a refusal on a background write carries a fixed message, and create_stage answers it with a stable tool result. The host's message never reaches the model. - A byte store declaring signsReadUrls without a signer degrades to direct bytes with a warning instead of failing reads, and the collector no longer requires signing. - DocumentActor is discriminated by source ('request' with the principal, 'background' without one). - Library ids the read path cannot address are dropped, and a provider answer is capped at 5000 ids. - The boot-time ownership backfill logs how many courses it adopted without hooks. - The server persistence provider no longer exposes an unscoped document store. - The writesOutsideRegistryDatabase trust boundary and the scope of upload admission (HTTP uploads, not server-generated media) are documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): refuse misspelled hooks on host classes too A class instance skipped the unknown-key check, so a misspelled hook such as authorizeCreat registered cleanly and never ran, leaving creation ungated. Both configurePersistenceHooks and configureAssetByteStore now reject any function-valued property of a class instance (own or on its prototype chain up to Object.prototype, constructor excluded) that is not a known key, and any unknown key of a plain object. The error names the key and suggests the known key within edit distance 2. Host classes keep their helpers private (#helper) or register a plain object; the README says so. Also: - isDocumentWriteRefusedError requires a string message from a cross-copy candidate, and documents that the name is the effective discriminator. - CHANGELOG: ServerPersistenceProvider no longer exposes an unscoped documentStore; use the owner-bound store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence): owner identity seam, phase 5 — tenancy lives in stage_meta only * feat(persistence): owner identity seam, phase 5 — tenancy lives in stage_meta only Course ownership was recorded twice: in stage_meta, which already answers every access decision, and in document_stages.owner_id, which the storage package scoped by. Two tenancy records double what a later claim has to re-key, and a host that keeps ownership in its own table had to rewrite the package's SQL to strip the column. @openmaic/storage 0.34.0: - PgDocumentStore never reads or writes document_stages.owner_id. An owner-bound store scopes listings, writes, deletes, the freshness manifest and folder membership through the host's ownership relation, named with the new documentOwnership option ({ table, stageIdColumn, ownerIdColumn, tombstoneColumn, claimOnCreate }, or false for no document scoping). Identifiers are validated, never quoted into SQL from arbitrary input. - forOwner / ownerId without documentOwnership throws at construction, so a direct consumer cannot upgrade into a store that silently scopes nothing. An unbound store is tenant-agnostic. - claimOnCreate inserts the ownership row in the create transaction; a concurrent create of the same id by another owner rolls back. - folders: false lets a host whose document_stages has no folder_id use the store; folder ids are unique per owner, so membership goes through the ownership relation too. - AssetCollector reads each document's owner from documentOwnership for assetReferencePrincipals, and refuses the function without it. - Schema: fresh installs get no owner_id column and none of its indexes; an existing column is kept for one release, made nullable with its default dropped (each ALTER guarded by a catalog check, so a replay takes no lock), and its two indexes are dropped. document_stages_folder_idx replaces the owner/folder index. Application: - The owner-bound store binds the package to stage_meta (STAGE_META_OWNERSHIP) and lists through it in one query; the asset collector schedule passes the same relation. - The boot backfill copies column-only owners into stage_meta before any store is built, only when the legacy column exists, idempotently, and logs how many it adopted and how many disagree (stage_meta stands). Tests cover a fresh install and an upgrade from the previous schema on PGlite and PostgreSQL 16, a host table with neither ownership nor folder columns, folder isolation across owners sharing a folder id, and the concurrent-create race. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): serialize schema bootstrap; guard unscoped owner-bound stores Review follow-ups for the tenancy change. - Concurrent starts: two instances starting against one database raced on the schema DDL (a duplicate pg_class / pg_type entry, or "tuple concurrently updated"), failing one instance's first request. Every schema bootstrap the application runs -- the persistence provider's sequence (runtime, documents, stage_meta and its ownership backfill, owner materials, assets), the agent-session, session-material and user-skill stores, and the asset collector's -- now runs under one PostgreSQL advisory lock held on a dedicated connection and released in finally. It lives in the application because what needs serializing is the whole sequence, including tables the package does not own. A PostgreSQL test boots eight instances at once, four rounds each, on a fresh and on an upgraded database; without the lock it fails every run. - documentOwnership: false on an owner-bound PgDocumentStore now also requires allowCrossOwnerDocumentAccess: true, so binding an owner for folders or asset principals cannot silently expose every owner's documents. - Documented that an ownership relation must cascade with the document rows (a leftover row keeps the id reserved for its owner, which is also how a host keeps retired ids from being reused), with tests for both. Leftover rows are deliberately not made claimable: a host cannot be told apart from one that keeps them as retirement markers. - Upgrade notes: the one-time table locks on the first start, and the out-of-band CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY path that leaves the start with nothing to build or drop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(persistence): leave the schema bootstrap lock out of the held-lock check The create-hooks suite asserts that its host lock is released with the create transaction by counting granted advisory locks database-wide. A suite booting a provider against the same database at that moment now holds the schema bootstrap lock, which is not the lock under test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(auth): owner identity seam, phase 6 — claiming anonymous work on sign-in * feat(storage): owner merge primitives for claiming anonymous work (0.35.0) Add the per-store pieces a host needs to move one owner's data to another inside a single transaction of its own: - reassignDocumentFolders(tx, { fromOwnerId, toOwnerId, documentOwnership }) moves folders and filing: a same-named folder merges into the target's, a colliding id is renumbered, and filing is rewritten in one statement from the old ids. - PgAssetStore.reassignPrincipal(fromKey, toKey) moves a principal's entries under both principals' write locks, in key order; quota is not re-checked. - PgUserSkillStore.mergeOwner(from, to) moves skills, renaming a live handle the target already uses with the first free numeric suffix. - PgUserSkillStore takes a resolveFinalOwner hook, run as the create transaction's first statement, like the agent-session store's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(auth): owner identity seam, phase 6 — claiming anonymous work on sign-in A visitor who works anonymously and then signs in can move that work to their account in one transaction, and the anonymous id is retired afterwards. - Principal: pendingClaim { fromOwnerId, assurance }. The trusted-proxy built-in sets it when a gateway request also carries a valid anonymous cookie; the anonymous and shared-team built-ins never do. Authenticators may implement describeStoredOwner, canonicalize and clearPendingClaim; principalFromStoredOwner describes a stored id for work without a request. - Claims: claimOwner / claimPendingOwner and registerClaimParticipant (sealed on first claim). One transaction, both owners' identity locks taken exclusively in key order, participants in a fixed order (folders, courses, materials, agent sessions, skills, runtime, assets), recorded in the new owner_merges table. Idempotent per pair; refuses a non-anonymous source, an anonymous target, a source already claimed elsewhere, and chains. Same-named folders merge, colliding folder ids are renumbered, colliding skill handles get a suffix, and quotas are not applied to what moves. - Identity lock: every owner write (documents, folders, asset allocations, material registration, agent sessions, skills) takes the owner's advisory lock in shared mode first, so a write racing a claim is moved or refused, never orphaned or deadlocked. - Retired ids: request writes are refused with 403 OWNER_RETIRED; agent runs and media generation that started before the claim follow the id to the account (forwarding document store, canonicalized stage probes, forwarded generated assets and skills). - Trigger: POST /api/identity/claim (same-origin JSON only; clears the anonymous cookie), or OWNER_CLAIM_TRIGGER=auto on the first request carrying a pending claim. The runtime contract's learner merge now performs the same claim, allowed only for the request's own pending claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(persistence): count only the suite's own advisory locks after concurrent creates The check that a host's transaction-scoped lock is released counted every granted advisory lock in the database. Suites that run against the same database at the same time hold advisory locks of their own (a schema bootstrap, the owner identity locks a claim takes), which made it fail depending on timing. Count only locks held by this suite's connections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(auth): use a neutral example table for host claim participants Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(storage): owner merge fixes and store policy errors over HTTP (review round 1) - PgUserSkillStore.mergeOwner reserves every handle either owner holds before renaming: a claimed skill whose handle the target uses could be renamed into a handle another claimed skill still held, which violated the per-owner unique index and made that merge fail every time. - PgUserSkillStore.list orders ties by id. - PgRuntimeStore takes resolveFinalLearner(tx, learnerKey), run as the create transaction's first statement, so a host can fence session creates against its owner merges; reassignLearner(from, to) re-keys sessions without re-validating them, so one session a newer version wrote cannot block a merge. - Every HTTP handler (runtime, documents, assets) answers a DocumentWriteRefusedError as 403 with its code, and the new StorageBusyError (code, message, retryAfterSeconds) as 503 with Retry-After. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): claims review round 1 — fenced runtime creates, bounded locks, retired-cookie recovery - Runtime session creates take the owner's identity lock as the create transaction's first statement and refuse a retired owner, closing the window in which a create racing a claim was left under the retired id. - Lock waits are bounded: a claim waits OWNER_CLAIM_LOCK_WAIT_MS (default 5 s) for its identity locks, a write OWNER_WRITE_LOCK_WAIT_MS (default 30 s); running out, or losing a deadlock or serialization race, answers 503 OWNER_BUSY with Retry-After on the claim routes and on every fenced write. - Every 403 OWNER_RETIRED carries the Set-Cookie values that drop the retired anonymous credential (the anonymous built-in now implements clearPendingClaim), so a browser whose claim response was lost recovers. Writes by id to rows that moved (skill delete, session messages) answer OWNER_RETIRED instead of not-found or a bare 403. - OWNER_CLAIM_TRIGGER=auto no longer claims ahead of the explicit claim routes, which now report their own claim instead of a false refusal. - The owner-events stream tells a retired owner only that it moved, never the claiming account's id. - Creates of one course id take turns (a per-id advisory lock before the ownership probe), so a concurrent second create saves as an update instead of failing as reserved-document. - A claim's source must be described as anonymous by the authenticator; the write fences skip the retirement lookup for every other owner. The host canonicalize hook is removed: forwarding is core's owner_merges alone. - The folders participant locks the source's stage_meta rows first; runtime sessions are re-keyed without re-validation; the owner asset store fences every allocation and refuses to allocate without transactions; background skill reads and patches follow a mid-run claim; the forwarding document store forwards methods only. - Docs: when a pending claim can arise with the shipped authenticators, the anonymous cookie as a bearer credential, bounded waits, the exact scope of OWNER_RETIRED, and claims narrowed to what the tests show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): claims review round 2 — merge-record guard, boot-checked lock waits, stream credential drop - owner_merges records claims of anonymous owners only, since the write fences enforce retirement only for ids the authenticator describes as anonymous. Reading a row that retires any other owner now fails loudly instead of being followed but not enforced. The comment that pointed hosts at claimOwner for their own account merges is corrected: a host merging two signed-in accounts moves the rows itself and refuses the merged-away account in its authenticator. describeStoredOwner must classify ids stably. - OWNER_WRITE_LOCK_WAIT_MS and OWNER_CLAIM_LOCK_WAIT_MS are validated at boot with the same parser the lock paths use, so a malformed value stops the server instead of failing every write. - The owner-events stream answers a connect by an already retired identity with one owner_moved event and the Set-Cookie values that drop the retired credential, so a stale tab's reconnect loop ends after one round trip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * chore(auth): final polish before landing the owner identity seam * chore(auth): final polish before landing the owner identity seam - Pi routes refuse a rejected credential with the seam's INVALID_CREDENTIAL body. - Drop the unused LegacyAssetMutations alias and resolveLockWaitMs re-export. - Scope the document_stages.owner_id deprecation wording: the store no longer reads or writes it; the boot backfill reads it and a claim mirrors ownership into it for rollback. - Document OWNER_CLAIM_TRIGGER and the two lock-wait variables in .env.example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(storage): 0.35.1 for the README wording fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(identity): one identity entry point with composable auth methods * feat(identity): compose owner auth methods behind one entry point Owner resolution now asks an ordered list of owner auth methods instead of one configured authenticator. Each method answers `authenticated`, `not-applicable` (no credential of its kind) or `invalid` (its credential is present but bad). Core keeps the first `authenticated` answer, refuses any `invalid` answer with 401 INVALID_CREDENTIAL without asking later methods, and when no method applies falls back to the anonymous cookie owner, or answers 401 when the host turned the fallback off. Route handlers and Server Actions share the same order and per-request memoization. - configureOwnerAuthentication({ methods, anonymousFallback? }) replaces configureOwnerAuthenticator: single-shot, sealed on first use, and validated at registration and at boot. - anonymousCookie becomes the built-in fallback method; sharedTeam becomes a method, selected by PERSISTENCE_SHARED_OWNER_ID as before when nothing is registered, and kept beside host methods only when the host lists sharedTeamAuthMethod() last. The variable set beside a registration that leaves it out fails the boot. - Core attaches pendingClaim itself: a host method's non-anonymous principal on a request that also carries a valid anonymous cookie. A method may not set one. Claim cookie clearing and OWNER_RETIRED recovery go through the anonymous method's clearCredential. - principalFromStoredOwner consults the anonymous method first, then the configured methods' describeStoredOwner in order. - Remove the built-in trusted-proxy header authenticator with OWNER_AUTHENTICATOR and every TRUSTED_PROXY_* variable. The boundary test now also keeps the resolution internals inside lib/server/identity/ and asserts no source reads the removed variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(identity): document owner auth methods and a gateway JWT recipe Rewrite the owner identity section around the ordered auth methods: the three answers and what resolution does with each, the anonymous fallback, registration and its boot checks, and the sharedTeam rules. Replace the removed built-in gateway authenticator's documentation with a short host-owned recipe that verifies a gateway-forwarded signed JWT (oauth2-proxy, Cloudflare Access, IAP) against the identity provider's keys with the jose library. Update the claim section for the core-owned claim candidate, and the Chinese README, SECURITY.md, CHANGELOG and the deployment docs in every locale to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(identity): tighten retired-owner cookies, retired config and the boundary - On 403 OWNER_RETIRED, core now calls clearCredential only on the anonymous fallback and on methods that declare `issuesAnonymousOwners: true`, so an account method's session cookie is never cleared there. A clearCredential that throws is logged and skipped instead of turning the refusal into a 500. - Setting any retired OWNER_AUTHENTICATOR / TRUSTED_PROXY_* variable fails the boot with a pointer to the auth-methods docs, instead of silently serving every request anonymously. Only the names are matched, in one module the boundary test checks never reads a value. - Add lib/server/identity/host/ for host auth methods. The boundary test now scans core identity files for gateway identity headers too, polices reads of an incoming Authorization header everywhere but the host directory, and keeps resolution internals out of host code. - Document on OwnerAuthMethodResult that a present but unusable credential must answer `invalid`, never `not-applicable`, and that a method that cannot decide throws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(identity): fix the gateway JWT recipe's error split and add notes - The recipe now answers `invalid` only for token failures (expired, claim validation, malformed JWS/JWT, bad signature, no or several matching keys, disallowed or unsupported algorithm) and rethrows everything else: jose reports a key endpoint that is down, answers non-200 or returns unparsable data as a generic JOSEError, which the previous version turned into a 401 for every user. - The recipe file goes in lib/server/identity/host/, with an accurate description of what the boundary test enforces. - Document invalid versus not-applicable for malformed credentials, issuesAnonymousOwners, the boot failure on retired variables, and that the claim candidate is an unsigned bearer cookie a sibling subdomain can shadow (serve on a registrable domain of its own; cookie hardening is deferred). Mirror the notes in the Chinese README, SECURITY.md and the changelog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
17 KiB
TypeScript
324 lines
17 KiB
TypeScript
/**
|
|
* The stage read/patch toolset — the agent's document-level DSL surface.
|
|
*
|
|
* This layer combines the generic stage DSL (`read_stage`, `patch_stage`,
|
|
* `grep_stage` from `./dsl-tools`) with page generation, playback, deck
|
|
* structure, media promotion, and preview tools, plus the stage-level CRUD
|
|
* they need (`create_stage`, folder organization, `rename_stage`, and
|
|
* `read_stage_outline` from `./curriculum-tools`) for the background runner.
|
|
*
|
|
* What this layer owns:
|
|
*
|
|
* - **Owner-scoped writes.** Every tool receives ONE store bound to the run's
|
|
* session owner. The owner never appears in a model-visible parameter;
|
|
* stages are readable by id, while foreign writes are refused.
|
|
* - **Sequential scheduling for document writers.** `patch_stage` loads the
|
|
* whole document, applies its change in memory and writes the scene back.
|
|
* The agent routinely emits several writers as PARALLEL tool calls in one
|
|
* turn, and then they all load the same snapshot and overwrite each other —
|
|
* the last writer wins, every sibling op is lost while still reporting
|
|
* success. The write set is derived from the shared
|
|
* `STAGE_WRITER_TOOL_NAMES` registry (the same list that arms client-side
|
|
* write ownership) and declared `executionMode: 'sequential'` to pi, so a
|
|
* batch containing a writer runs entirely sequentially and reads next to a
|
|
* write observe committed state.
|
|
* - **The DSL compatibility prompt block.** `DSL_TOOLS_PROMPT` teaches the
|
|
* model the current tool names (legacy transcripts named the retired
|
|
* per-type editors) and is layered into every runner prompt by
|
|
* `buildRunnerCoursePrompt` (runner-contract.ts).
|
|
*/
|
|
import type { AgentTool } from '@earendil-works/pi-agent-core';
|
|
import type { DocumentFolderStore, DocumentStore, MaicDocument } from '@openmaic/storage';
|
|
import type { Stage } from '@openmaic/dsl';
|
|
|
|
import type { Scene } from '@/lib/types/stage';
|
|
import { STAGE_WRITER_TOOL_NAMES } from '@/lib/agent-runtime/stage-writer-tools';
|
|
import { buildDslCourseTools, DSL_COURSE_TOOL_NAMES } from './dsl-tools';
|
|
import { CURRICULUM_ALLOWLIST } from './curriculum-tools';
|
|
import { buildGenerationTools, GENERATION_TOOL_NAMES } from './generation-tools';
|
|
import { buildCourseAudioAndDeckTools, COURSE_AUDIO_DECK_TOOL_NAMES } from './course-edit/tools';
|
|
import { buildMaterialMediaTool, MATERIAL_MEDIA_TOOL_NAME } from './material-media';
|
|
import { RENDER_SCENE_PREVIEW_TOOL_NAME } from './scene-preview';
|
|
import { buildImportPptxTool, IMPORT_PPTX_TOOL_NAME, type ImportPptxToolDeps } from './import-pptx';
|
|
import {
|
|
buildGenerateImageTool,
|
|
GENERATE_IMAGE_TOOL_NAME,
|
|
type GenerateImageToolDeps,
|
|
} from './generate-image';
|
|
import {
|
|
buildGenerateVideoTool,
|
|
GENERATE_VIDEO_TOOL_NAME,
|
|
hasConfiguredVideoGeneration,
|
|
type GenerateVideoToolDeps,
|
|
} from './generate-video';
|
|
import type { SceneTtsInput, SceneTtsSummary } from './scene-tts';
|
|
import type { LoadedSkill } from './skills';
|
|
|
|
export type CourseDocument = MaicDocument<Scene, Stage>;
|
|
export type CourseStore = DocumentStore<Scene, Stage> & DocumentFolderStore;
|
|
|
|
/** Progress metadata emitted on the durable `checkpoint` channel after a write. */
|
|
export interface CheckpointInfo {
|
|
tool: string;
|
|
detail: string;
|
|
/** The stage the checkpoint wrote to — unlocks the workbench's real-time
|
|
* sync for courses that were opened, not minted, by this session. */
|
|
stageId?: string;
|
|
sceneId?: string;
|
|
order?: number;
|
|
title?: string;
|
|
sceneType?: string;
|
|
/** Active skill whose persisted constraints were checked after this write. */
|
|
skill?: string;
|
|
/** Non-blocking structural diagnostics for the persisted stage. */
|
|
skillViolations?: string[];
|
|
}
|
|
|
|
export interface CourseToolDeps {
|
|
/** The owner-bound document store of the run's session owner. */
|
|
store: CourseStore;
|
|
/**
|
|
* Fail-closed owner probe for every model-declared stage target: a stage
|
|
* that is not owned (foreign, missing, or tombstoned) is refused before the
|
|
* tool ever touches the store. The refusal never echoes which state it was.
|
|
*/
|
|
stageAccess: (
|
|
stageId: string,
|
|
) => Promise<{ kind: 'owned' | 'missing' | 'foreign' | 'tombstoned' }>;
|
|
/** Emitted after a successful document write (the durable `checkpoint` event). */
|
|
onCheckpoint: (info: CheckpointInfo) => void;
|
|
/** The session id, recorded on the document as the producer reference. */
|
|
sessionId?: string;
|
|
/**
|
|
* The owner recorded on the run's durable session. Media the run generates is
|
|
* allocated in this owner's asset partition.
|
|
*/
|
|
ownerId?: string;
|
|
/** Cancel generation, preview, and synthesis when the run stops. */
|
|
abortSignal?: AbortSignal;
|
|
/** Test seam for the neutral TTS path. */
|
|
synthesizeTts?: (input: SceneTtsInput) => Promise<SceneTtsSummary>;
|
|
/** Resolve the skill that owns structural diagnostics for the current turn. */
|
|
getActiveSkill?: () => LoadedSkill | null;
|
|
}
|
|
|
|
/**
|
|
* Every tool in this toolset that is a read-modify-write of the persisted
|
|
* course document: it loads the document (or one scene), applies its change in
|
|
* memory, and writes a whole scene or the whole document back.
|
|
*
|
|
* Derived from the shared `STAGE_WRITER_TOOL_NAMES` (the same list that arms
|
|
* client-side write ownership) so the scheduler and the workbench can never
|
|
* disagree about who writes. `rename_stage` is scheduled in the curriculum
|
|
* toolset, so it is excluded from this course-tool subset.
|
|
*/
|
|
export const DOCUMENT_WRITING_TOOLS: ReadonlySet<string> = new Set<string>(
|
|
[...STAGE_WRITER_TOOL_NAMES].filter((name) => name !== 'rename_stage'),
|
|
);
|
|
|
|
/**
|
|
* Declare the document writers as `executionMode: 'sequential'`.
|
|
*
|
|
* None of them takes a lock: each is `load whole document → apply one op in
|
|
* memory → write the whole scene back`. The agent routinely emits several of
|
|
* them for one page as PARALLEL tool calls in a single turn, and then they all
|
|
* load the same snapshot and overwrite each other. The damage is silent — the
|
|
* last writer wins, every sibling op is lost while still reporting success, and
|
|
* an element added by one call is erased by a sibling whose snapshot predates
|
|
* it.
|
|
*
|
|
* pi is the scheduler, so the requirement is declared to pi rather than
|
|
* hand-rolled here: in `executeToolCalls` (pi-agent-core agent-loop.js) a batch
|
|
* containing ANY call to a `sequential` tool runs entirely through
|
|
* `executeToolCallsSequential`. That is deliberately stronger than serializing
|
|
* only the writers against each other — it also orders the READS in the same
|
|
* batch, so a `read_stage` next to an edit observes committed state instead of
|
|
* the pre-write snapshot, which is the other half of what made the agent loop.
|
|
*
|
|
* The cost is that a batch mixing a write with otherwise-parallel reads loses
|
|
* that parallelism. That is the intended trade: a lost write is not
|
|
* self-correcting, a slower turn is.
|
|
*/
|
|
export function markDocumentWritersSequential(
|
|
tools: AgentTool<never, never>[],
|
|
): AgentTool<never, never>[] {
|
|
return tools.map((tool) => {
|
|
const named = tool as unknown as { name: string };
|
|
if (!DOCUMENT_WRITING_TOOLS.has(named.name)) return tool;
|
|
return { ...tool, executionMode: 'sequential' } as unknown as AgentTool<never, never>;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wrap every tool that names a stage in the fail-closed owner probe.
|
|
*
|
|
* This is the single legality boundary of the open-domain stage toolsets: each
|
|
* tool resolves its target stage ONCE, before any store IO. A probe that is
|
|
* not `owned` (foreign, missing, or tombstoned) refuses with the same
|
|
* not-yours message the curriculum toolset uses, and the tool text never
|
|
* echoes which state it was. Tools without a `stageId` parameter pass through
|
|
* untouched. The runner applies it to the course/DSL toolset and to the roster
|
|
* toolset — the reference wraps every stageId-bearing tool of its merged
|
|
* course toolset the same way.
|
|
*/
|
|
export function withOwnerStageAuthorization(
|
|
tools: AgentTool<never, never>[],
|
|
deps: Pick<CourseToolDeps, 'stageAccess'>,
|
|
): AgentTool<never, never>[] {
|
|
return tools.map((tool) => {
|
|
const original = tool.execute.bind(tool);
|
|
return {
|
|
...tool,
|
|
async execute(...args: Parameters<typeof tool.execute>) {
|
|
const [callId, rawParams, signal, onUpdate] = args;
|
|
const params = rawParams as unknown as { stageId?: unknown };
|
|
const stageId = typeof params.stageId === 'string' ? params.stageId.trim() : '';
|
|
if (stageId) {
|
|
const access = await deps.stageAccess(stageId);
|
|
if (signal?.aborted) throw new Error('aborted');
|
|
if (access.kind !== 'owned') {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: 'The stage was not found, or does not belong to this session user. Use list_folder_stages to see the stages you can work on.',
|
|
},
|
|
],
|
|
details: { refused: true, stageId },
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
return original(callId, rawParams, signal, onUpdate);
|
|
},
|
|
} as unknown as AgentTool<never, never>;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The stage read/patch toolset registered on the runner: the three generic DSL
|
|
* tools (with the page generation, deck, and media tools), every one of them
|
|
* owner-gated by `withOwnerStageAuthorization`, and `patch_stage` marked
|
|
* sequential through the shared writer registry. `import_pptx` and
|
|
* `generate_image` are always part of the course toolset; `generate_video` is
|
|
* capability-registered and exists exactly when a video provider is
|
|
* configured, so the model never sees a tool that can only throw. Scene
|
|
* preview is registered separately by the runner with its own probe, so it
|
|
* never double-gates.
|
|
*/
|
|
export function buildDslCourseToolset(
|
|
deps: CourseToolDeps &
|
|
Partial<ImportPptxToolDeps> &
|
|
Partial<GenerateImageToolDeps> &
|
|
Partial<GenerateVideoToolDeps>,
|
|
): AgentTool<never, never>[] {
|
|
const tools = [
|
|
...buildGenerationTools(deps),
|
|
buildImportPptxTool(deps),
|
|
buildGenerateImageTool(deps),
|
|
...(hasConfiguredVideoGeneration(deps) ? [buildGenerateVideoTool(deps)] : []),
|
|
...buildCourseAudioAndDeckTools(deps),
|
|
...(deps.sessionId ? [buildMaterialMediaTool({ sessionId: deps.sessionId })] : []),
|
|
...buildDslCourseTools(deps),
|
|
] as unknown as AgentTool<never, never>[];
|
|
return markDocumentWritersSequential(withOwnerStageAuthorization(tools, deps));
|
|
}
|
|
|
|
/** The exact registered tool names of this slice's toolset. */
|
|
export function buildCourseAllowlist(
|
|
videoDeps: Partial<GenerateVideoToolDeps> = {},
|
|
): ReadonlySet<string> {
|
|
return new Set([
|
|
...DSL_COURSE_TOOL_NAMES,
|
|
...GENERATION_TOOL_NAMES,
|
|
IMPORT_PPTX_TOOL_NAME,
|
|
GENERATE_IMAGE_TOOL_NAME,
|
|
...(hasConfiguredVideoGeneration(videoDeps) ? [GENERATE_VIDEO_TOOL_NAME] : []),
|
|
...COURSE_AUDIO_DECK_TOOL_NAMES,
|
|
MATERIAL_MEDIA_TOOL_NAME,
|
|
RENDER_SCENE_PREVIEW_TOOL_NAME,
|
|
...CURRICULUM_ALLOWLIST,
|
|
]);
|
|
}
|
|
|
|
export const DSL_TOOLS_PROMPT = [
|
|
'Some installed skills and older transcripts were written for earlier tool names. Translate on sight: read_scene → read_stage (path=/scenes/<order|id>); edit_slide / edit_quiz / edit_widget / edit_actions / edit_pbl → patch_stage (same JSON-pointer ops, target the scene); read_course → read_stage; patch_course → patch_stage; grep_course → grep_stage; generate_outline → (plan in conversation, then create_stage + one generate_scene per page with an explicit brief); generate_roster → set_roster. Never call the legacy names.',
|
|
'The generic DSL tools replace read_scene and the per-type edit tools.',
|
|
'Every read_stage, patch_stage and grep_stage call requires an explicit stageId obtained from create_stage.',
|
|
'Example: read_stage {"stageId":"stage-...","path":"/scenes/1","detail":"source"}. Use paths "", /outline, /scenes/<1-based order|sceneId>, and /scenes/<...>/actions.',
|
|
'Before patching a structure you have not touched, read the stage-dsl skill and its matching reference chapter.',
|
|
'Always read_stage detail:"source" for the target, patch_stage with the smallest /content/... or /actions/... JSON Pointer, then read_stage again to verify.',
|
|
'For one targeted change inside a large HTML or long text field, use patch_stage op "str_replace" (path, oldText, newText; set replaceAll only when the anchor repeats) instead of rewriting the whole field with set.',
|
|
'Use grep_stage for literal stage-wide text/source search.',
|
|
'Start with detail:"tree" to see structure; read source only for the subtree you will edit; for long content, prefer grep_stage over paging with offset.',
|
|
'Use generate_scene once per page: each successful call is a durable checkpoint. Use list_scenes to inspect persisted pages and generate_actions to rebuild playback actions for one page.',
|
|
'Use duplicate_scene to copy a layout, edit_deck for retitle/insert/delete/reorder, and generate_tts after narration edits. Page-list writers keep the saved outline numbering aligned with the real pages.',
|
|
'To fill an uploaded .pptx into an existing stage as appended pages with its original slides kept, read the `pptx-import` skill first (it carries the import-and-repair sequence) and use `import_pptx` after `create_stage` — never extract_material + generate_scene for a layout-preserving import. The stage keeps its own title; the PPT is content, not the classroom identity.',
|
|
'When the page needs a new visual rather than an existing URL, call `generate_image` first, then apply its returned `src` with `patch_stage` set or add an image element; generate_image never edits the page.',
|
|
'When a NEW page needs visuals, obtain every real src first by reusing material or calling generate_image, then pass each image src with its description and dimensions in `generate_scene.media` so the content model sees the media while composing the page; media generation tools never edit the page.',
|
|
'generate_video is asynchronous: it returns a `gen_vid_...` placeholder immediately and the video completes in the background. Patch the placeholder onto a video element with patch_stage right away; the page updates itself when the video is ready.',
|
|
'Use use_material_media before placing session image, video, or audio bytes into a page. Use render_scene_preview selectively to inspect a persisted page when the render capability is available.',
|
|
].join(' ');
|
|
|
|
/** Base runner identity/environment lines, shared by every runner prompt. */
|
|
export const COURSE_SYSTEM_PROMPT = [
|
|
'You are a capable assistant working in a durable background session.',
|
|
'Complete the user request carefully and explain the result clearly.',
|
|
'The conversation may pause, restart on another worker, or receive follow-up messages.',
|
|
'Treat earlier conversation messages as durable context.',
|
|
'Do not claim access to tools or data that are not present in this session.',
|
|
'Use ask_user only when a decision genuinely belongs to the user.',
|
|
'Make every question self-contained and concise.',
|
|
'Offer stable, unique option ids when choices are useful.',
|
|
'After ask_user succeeds, stop and wait for the next user message.',
|
|
'Do not answer your own question or invent the user decision.',
|
|
'If no clarification is needed, answer directly without calling a tool.',
|
|
'Follow later user messages as updates to the same conversation.',
|
|
'Be honest about uncertainty and unavailable capabilities.',
|
|
"Reply in the user's language unless the user requests another language.",
|
|
].join('\n');
|
|
|
|
interface CoursePromptBlocks {
|
|
/** Pi's native `<available_skills>` discovery block. */
|
|
availableSkills?: string;
|
|
/** Multi-stage workflow guidance (explicit stage ids and folders). */
|
|
curriculum?: string;
|
|
/** Present exactly when the web_search tool is registered. */
|
|
search?: string;
|
|
/** Policy boundary for content fetched from session-observed URLs. */
|
|
untrustedContent?: string;
|
|
/** fetch_url usage guidance (the tool is always registered). */
|
|
fetch?: string;
|
|
/** Compatibility guidance for installed legacy skills and resumed transcripts. */
|
|
dslTools?: string;
|
|
/**
|
|
* Session-materials listing. Present only when the session actually has
|
|
* materials (reference semantics: the block must not appear for a session
|
|
* with nothing to read).
|
|
*/
|
|
materials?: string;
|
|
/** Roster guidance (list_voices / set_roster; always registered). */
|
|
roster?: string;
|
|
/** Voice-cloning guidance (clip_audio / register_voice; always registered). */
|
|
voice?: string;
|
|
}
|
|
|
|
/**
|
|
* The agent's system prompt, assembled from the capabilities this session
|
|
* actually has. The DSL compatibility block is always present; every other
|
|
* block is included only when the corresponding capability is registered.
|
|
*/
|
|
export function courseSystemPrompt(blocks: CoursePromptBlocks): string {
|
|
const parts = [COURSE_SYSTEM_PROMPT];
|
|
if (blocks.availableSkills) parts.push('', blocks.availableSkills);
|
|
if (blocks.curriculum) parts.push('', blocks.curriculum);
|
|
if (blocks.search) parts.push('', blocks.search);
|
|
if (blocks.dslTools) parts.push('', blocks.dslTools);
|
|
if (blocks.fetch) parts.push('', blocks.fetch);
|
|
if (blocks.untrustedContent) parts.push('', blocks.untrustedContent);
|
|
if (blocks.materials) parts.push('', blocks.materials);
|
|
if (blocks.roster) parts.push('', blocks.roster);
|
|
if (blocks.voice) parts.push('', blocks.voice);
|
|
return parts.join('\n');
|
|
}
|