From 7231ec4799afa8174f01a87b78b48d49a6d235fa Mon Sep 17 00:00:00 2001 From: Chayapat Sriwattanachote Date: Sat, 7 Nov 2026 21:50:58 +0700 Subject: [PATCH 1/4] feat(s16): persistent Bots from exact proposals and bounded Bot-to-Bot delegation BOT16-01 and DELEG16-01 behind explicit bots: true (set by the generated installer): - protected Bot directory (bots.sqlite): profiles, exact proposals bound to the policy revision and team resources, immutable conversation-to-Bot bindings - one-press acceptance installs a standing-policy revision that adds the Bot to every team rule naming the main Bot; POLICY keeps running work across a revision that only changes other Bots' membership - one Bot per conversation with its route, responsibility and excluded resources - archive fences the Bot's catalog identity, stops its unfinished work, pauses its routines and refuses new messages; restore reactivates the same identity - credential-free duplicate, export and import, each as a new proposal - delegate tool: peer and temporary assignments inside the parent's frozen scope, slices charged to the parent, own drain pool, TEAM handoff and DATA15 ancestry checked before every child send; stop cascades from parent to children - main Bot's propose_bot tool drafts an inert proposal card in the chat - s16-client-bots/v1 owner API and React Bots pages, roster, Talk to choice, proposal and child cards in English and Thai Closes #412 Closes #413 --- ops/package/installer/s15.ts | 4 +- src/i18n/en.ts | 69 +++++ src/i18n/th.ts | 69 +++++ src/s15/composition.ts | 57 ++++- src/s15/conversation/application.ts | 108 +++++++- src/s15/conversation/owner-view.ts | 19 +- src/s15/conversation/policy-port.ts | 30 ++- src/s15/conversation/types.ts | 26 +- src/s15/main.ts | 2 + src/s15/memory/service.ts | 8 + src/s15/policy/defaults.ts | 9 +- src/s15/policy/service.ts | 17 +- src/s15/runtime-config.ts | 3 + src/s16/bots/delegation.ts | 170 +++++++++++++ src/s16/bots/service.ts | 375 ++++++++++++++++++++++++++++ src/s16/bots/types.ts | 123 +++++++++ src/s16/client/client.ts | 43 +++- src/s16/client/contracts.ts | 10 +- src/s16/http.ts | 26 +- src/s16/notifications/producers.ts | 2 +- src/s16/web/BotProposalCard.tsx | 66 +++++ src/s16/web/BotsView.tsx | 201 +++++++++++++++ src/s16/web/ComputersView.tsx | 6 +- src/s16/web/app.tsx | 76 ++++-- src/s16/web/handler.ts | 2 +- src/s16/web/style.css | 6 + tests/s16-bots.browser.test.ts | 100 ++++++++ tests/s16-bots.test.ts | 281 +++++++++++++++++++++ 28 files changed, 1847 insertions(+), 61 deletions(-) create mode 100644 src/s16/bots/delegation.ts create mode 100644 src/s16/bots/service.ts create mode 100644 src/s16/bots/types.ts create mode 100644 src/s16/web/BotProposalCard.tsx create mode 100644 src/s16/web/BotsView.tsx create mode 100644 tests/s16-bots.browser.test.ts create mode 100644 tests/s16-bots.test.ts diff --git a/ops/package/installer/s15.ts b/ops/package/installer/s15.ts index 870afa55..8f34e24d 100644 --- a/ops/package/installer/s15.ts +++ b/ops/package/installer/s15.ts @@ -40,7 +40,7 @@ const ref = (id: string) => ({ id, revision: 1, generation: 1 }); * the approved Codex subscription route for the owner's single device login (live availability follows that * login), no policy, budget or personal GitHub rule, and the Jit-managed local working computer whose QEMU runs * as the separate identity (`vmm: null` on a host without local VM support: the owner enrolls their own VM in - * the Web instead). Web Push notifications (NOTIFY16-01) and S16 working computers (TEAM16-01) are composed. `browser: null` selects the provisioned protected browser (BROW15-02): pinned Chromium that + * the Web instead). Web Push notifications (NOTIFY16-01), S16 working computers (TEAM16-01) and persistent Bots with delegation (BOT16-01/DELEG16-01) are composed. `browser: null` selects the provisioned protected browser (BROW15-02): pinned Chromium that * the controller verifies itself and the owner's approved-website list in the Web. GitHub and Google stay off. */ export function s15InstallerConfig(input: { installationId: string; vmm: QemuVmmOptions | null; origin?: string }): S15RuntimeConfig { @@ -52,6 +52,8 @@ export function s15InstallerConfig(input: { installationId: string; vmm: QemuVmm notifications: true, // TEAM16-01: per-work workspaces, owner-chosen private computers and 30-minute idle release. computers: true, + // BOT16-01/DELEG16-01: more persistent Bots from an accepted proposal, and bounded delegation between them. + bots: true, catalog: [{ kind: "bot", ref: ref("jit"), active: true }, { kind: "resource", ref: ref("computer"), active: true }, { kind: "route", ref: selection.route, active: true }, { kind: "model", ref: selection.model, active: true }], resourceBots: [], conversation: { ownerId: "owner", bot: ref("jit"), selection, route: { provider: "codex", model: "gpt-6.1-sol" }, limits: { modelCalls: 6, toolCalls: 16, outputBytes: 32768, activeMs: 300000 }, modelCallUpperBound: bound }, models: [{ selection, provider: "codex", modelId: "gpt-6.1-sol", account: ref("codex_account"), billing: ref("codex_subscription"), recipient: ref("codex_provider"), charge: "subscription", availability: "available", efforts: ["high"], effortEvidence: "requested_only", upperBound: bound, rank: { ordinary: 0, difficult: 0 } }], diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3a08f947..c083a73f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6144,4 +6144,73 @@ export const en: Record = { "s16.computers.unavailable": "Computers are not available on this installation.", "s16.computers.loadFailed": "Could not load computers ({reason}).", "s16.computers.empty": "No computer yet. Jit sets one up during installation.", + "s16.nav.allBots": "All Bots", + "s16.bots.title": "Bots", + "s16.bots.lead": "Each Bot has its own responsibility, conversations and memory. A new Bot uses the team's computer, connected apps and rules, so there is nothing else to set up.", + "s16.bots.mainName": "Jit", + "s16.bots.mainBadge": "Main Bot", + "s16.bots.new": "New Bot", + "s16.bots.import": "Import Bot", + "s16.bots.importFailed": "Could not use that file ({reason}).", + "s16.bots.name": "Name", + "s16.bots.responsibility": "Responsibility", + "s16.bots.responsibilityHint": "What this Bot looks after. It still works only within your rules.", + "s16.bots.model": "Model", + "s16.bots.modelDefault": "Same as Jit (default)", + "s16.bots.modelInstallation": "Installation default", + "s16.bots.modelUnavailable": "The chosen model is not available now, so new work uses the default.", + "s16.bots.assignments": "Other Bots can hand it tasks", + "s16.bots.resources": "Uses", + "s16.bots.resourcesHint": "Team defaults. Untick anything this Bot should not use.", + "s16.bots.review": "Review", + "s16.bots.nameRequired": "Enter a name.", + "s16.bots.proposalTitle": "New Bot: {name}", + "s16.bots.proposalBody": "Nothing is created until you press Create Bot. It works under the same rules as Jit.", + "s16.bots.proposalChat": "Jit suggested this Bot in the conversation.", + "s16.bots.proposalImport": "From an imported file.", + "s16.bots.proposalDuplicate": "A copy of an existing Bot: settings only, without conversations, memory or files.", + "s16.bots.missing": "Not on this installation, so left out: {items}", + "s16.bots.noResources": "No team resources", + "s16.bots.accept": "Create Bot", + "s16.bots.edit": "Edit", + "s16.bots.dismiss": "Not now", + "s16.bots.created": "{name} is ready.", + "s16.bots.stale": "This proposal changed or your rules were updated. Review it again.", + "s16.bots.save": "Save", + "s16.bots.saved": "Saved.", + "s16.bots.chat": "Start a chat", + "s16.bots.duplicate": "Duplicate", + "s16.bots.copyName": "{name} copy", + "s16.bots.export": "Export settings", + "s16.bots.archive": "Archive", + "s16.bots.archiveTitle": "Archive {name}?", + "s16.bots.archiveBody": "{name} stops its running work now and takes no new messages. Its routines pause. Conversations and memory are kept, and you can restore it later.", + "s16.bots.archiveRunning": "Stops {n} running tasks.", + "s16.bots.archiveRoutines": "Pauses routines: {names}", + "s16.bots.archiveDone": "Archived. Stopped {stopped} tasks and paused {paused} routines.", + "s16.bots.archived": "Archived", + "s16.bots.restore": "Restore", + "s16.bots.restored": "Restored. Turn its routines back on in Routines if you want them.", + "s16.bots.mainFixed": "Jit is the main Bot and always stays available.", + "s16.bots.conversations": "Conversations", + "s16.bots.noConversations": "No conversations yet.", + "s16.bots.assignment": "Assignment", + "s16.bots.failed": "Could not make the change ({reason}).", + "s16.bots.unknown": "Not sure the change was saved.", + "s16.bots.checkAgain": "Check again", + "s16.bots.loadFailed": "Could not load Bots ({reason}).", + "s16.bots.retry": "Try again", + "s16.bots.unavailable": "Bots are not available on this installation.", + "s16.bots.cancel": "Cancel", + "s16.bots.back": "All Bots", + "s16.bots.creationUnavailable": "New Bots need your rules first ({reason}).", + "s16.bots.pending": "Waiting for you", + "s16.bots.talkTo": "Talk to", + "s16.bots.archivedChat": "{name} is archived. Restore it on its page to continue this conversation.", + "s16.bots.assignmentFrom": "Task from {name}", + "s16.bots.temporary": "Temporary helper", + "s16.bots.children": "Handed to other Bots", + "s16.bots.openChild": "Open", + "s16.bots.stopChild": "Stop", + "s16.bots.childFailed": "Not finished ({reason})", }; diff --git a/src/i18n/th.ts b/src/i18n/th.ts index ea837535..793a8a55 100644 --- a/src/i18n/th.ts +++ b/src/i18n/th.ts @@ -6148,4 +6148,73 @@ export const th = { "s16.computers.unavailable": "การติดตั้งนี้ยังไม่มีหน้าคอมพิวเตอร์", "s16.computers.loadFailed": "โหลดรายการคอมพิวเตอร์ไม่สำเร็จ ({reason})", "s16.computers.empty": "ยังไม่มีคอมพิวเตอร์ จิตจะตั้งค่าให้ตอนติดตั้ง", + "s16.nav.allBots": "Bot ทั้งหมด", + "s16.bots.title": "Bot", + "s16.bots.lead": "Bot แต่ละตัวมีหน้าที่ บทสนทนา และความจำของตัวเอง Bot ใหม่ใช้คอมพิวเตอร์ แอปที่เชื่อมไว้ และกฎเดียวกับทีม จึงไม่ต้องตั้งค่าอะไรเพิ่ม", + "s16.bots.mainName": "จิต", + "s16.bots.mainBadge": "Bot หลัก", + "s16.bots.new": "สร้าง Bot ใหม่", + "s16.bots.import": "นำเข้า Bot", + "s16.bots.importFailed": "ใช้ไฟล์นี้ไม่ได้ ({reason})", + "s16.bots.name": "ชื่อ", + "s16.bots.responsibility": "หน้าที่", + "s16.bots.responsibilityHint": "สิ่งที่ Bot นี้ดูแล โดยยังทำงานภายใต้กฎของคุณเท่านั้น", + "s16.bots.model": "โมเดล", + "s16.bots.modelDefault": "เหมือนจิต (ค่าเริ่มต้น)", + "s16.bots.modelInstallation": "ค่าเริ่มต้นของเครื่องนี้", + "s16.bots.modelUnavailable": "โมเดลที่เลือกใช้ไม่ได้ตอนนี้ งานใหม่จึงใช้ค่าเริ่มต้น", + "s16.bots.assignments": "ให้ Bot อื่นมอบงานให้ได้", + "s16.bots.resources": "ใช้", + "s16.bots.resourcesHint": "ค่าเริ่มต้นของทีม เอาเครื่องหมายออกจากสิ่งที่ไม่ต้องการให้ Bot นี้ใช้", + "s16.bots.review": "ตรวจทาน", + "s16.bots.nameRequired": "ใส่ชื่อ", + "s16.bots.proposalTitle": "Bot ใหม่: {name}", + "s16.bots.proposalBody": "ยังไม่มีอะไรถูกสร้างจนกว่าจะกด สร้าง Bot และจะทำงานภายใต้กฎเดียวกับจิต", + "s16.bots.proposalChat": "จิตเสนอ Bot นี้ในบทสนทนา", + "s16.bots.proposalImport": "จากไฟล์ที่นำเข้า", + "s16.bots.proposalDuplicate": "สำเนาของ Bot ที่มีอยู่ เฉพาะการตั้งค่า ไม่รวมบทสนทนา ความจำ หรือไฟล์", + "s16.bots.missing": "ไม่มีในเครื่องนี้ จึงไม่ได้ใส่: {items}", + "s16.bots.noResources": "ไม่มีทรัพยากรของทีม", + "s16.bots.accept": "สร้าง Bot", + "s16.bots.edit": "แก้ไข", + "s16.bots.dismiss": "ไว้ทีหลัง", + "s16.bots.created": "{name} พร้อมแล้ว", + "s16.bots.stale": "ข้อเสนอนี้เปลี่ยนไปหรือกฎของคุณอัปเดตแล้ว ตรวจทานอีกครั้ง", + "s16.bots.save": "บันทึก", + "s16.bots.saved": "บันทึกแล้ว", + "s16.bots.chat": "เริ่มแชท", + "s16.bots.duplicate": "ทำสำเนา", + "s16.bots.copyName": "{name} สำเนา", + "s16.bots.export": "ส่งออกการตั้งค่า", + "s16.bots.archive": "เก็บถาวร", + "s16.bots.archiveTitle": "เก็บ {name} ถาวรไหม", + "s16.bots.archiveBody": "{name} จะหยุดงานที่กำลังทำทันทีและไม่รับข้อความใหม่ routine จะหยุดชั่วคราว บทสนทนาและความจำยังอยู่ และกู้คืนได้ภายหลัง", + "s16.bots.archiveRunning": "หยุดงานที่กำลังทำ {n} งาน", + "s16.bots.archiveRoutines": "หยุด routine ชั่วคราว: {names}", + "s16.bots.archiveDone": "เก็บถาวรแล้ว หยุด {stopped} งาน และหยุด routine ชั่วคราว {paused} รายการ", + "s16.bots.archived": "เก็บถาวรแล้ว", + "s16.bots.restore": "กู้คืน", + "s16.bots.restored": "กู้คืนแล้ว เปิด routine อีกครั้งได้ที่หน้า Routines ถ้าต้องการ", + "s16.bots.mainFixed": "จิตเป็น Bot หลักและพร้อมใช้งานเสมอ", + "s16.bots.conversations": "บทสนทนา", + "s16.bots.noConversations": "ยังไม่มีบทสนทนา", + "s16.bots.assignment": "งานที่ได้รับมอบ", + "s16.bots.failed": "ทำรายการไม่สำเร็จ ({reason})", + "s16.bots.unknown": "ไม่แน่ใจว่าบันทึกสำเร็จหรือไม่", + "s16.bots.checkAgain": "ตรวจอีกครั้ง", + "s16.bots.loadFailed": "โหลด Bot ไม่สำเร็จ ({reason})", + "s16.bots.retry": "ลองอีกครั้ง", + "s16.bots.unavailable": "เครื่องนี้ยังใช้งาน Bot ไม่ได้", + "s16.bots.cancel": "ยกเลิก", + "s16.bots.back": "Bot ทั้งหมด", + "s16.bots.creationUnavailable": "ต้องตั้งกฎก่อนจึงสร้าง Bot ใหม่ได้ ({reason})", + "s16.bots.pending": "รอคุณตัดสินใจ", + "s16.bots.talkTo": "คุยกับ", + "s16.bots.archivedChat": "{name} ถูกเก็บถาวรแล้ว กู้คืนได้ที่หน้าของ Bot เพื่อคุยต่อ", + "s16.bots.assignmentFrom": "งานจาก {name}", + "s16.bots.temporary": "ผู้ช่วยชั่วคราว", + "s16.bots.children": "มอบให้ Bot อื่น", + "s16.bots.openChild": "เปิด", + "s16.bots.stopChild": "หยุด", + "s16.bots.childFailed": "ไม่สำเร็จ ({reason})", } as const; diff --git a/src/s15/composition.ts b/src/s15/composition.ts index 824570ae..53a2b44e 100644 --- a/src/s15/composition.ts +++ b/src/s15/composition.ts @@ -3,6 +3,9 @@ import { isAbsolute, join, resolve } from 'node:path'; import { createS16ClientHandler } from '../s16/http'; import { ComputerScopes } from '../s16/computers/scopes'; import { ComputerDirectory } from '../s16/computers/service'; +import { BotDirectory } from '../s16/bots/service'; +import { createBotTools } from '../s16/bots/delegation'; +import { delegationLimits } from '../s16/bots/types'; import { createS16WebHandler } from '../s16/web/handler'; import { RoutineService, routineQuietDecision } from '../s16/routines/service'; import { NotificationService } from '../s16/notifications/service'; @@ -70,6 +73,9 @@ export interface S15CompositionOptions { * computers for a Bot, and idle release after `idleStopMs` (on-demand start). Opt-in; it changes no installed policy. * `sweepMs` is the idle-check period (0: only explicit `computers.sweep()` calls, for deterministic tests). */ computers?: { idleStopMs: number; sweepMs?: number }; + /** Explicit S16 persistent Bots and delegation (`s16-bots/v1`, BOT16-01/DELEG16-01, ADR 0088). Opt-in; it adds + * `bots.sqlite`. A new Bot joins the standing policy only when the owner accepts its exact proposal. */ + bots?: Record; /** Selected host transition. Status is read-only; its activity guard fences work and records released * activity durably. Composition cannot activate/release or import old authority through this port. */ adoption?: { service: Pick; authenticate(owner: OwnerActor): import('./adoption').AdoptionOwner }; @@ -132,7 +138,7 @@ export async function composeS15(options: S15CompositionOptions) { let githubConnections: GithubConnections | undefined; if (webGithubEnabled && options.disclosure) options = { ...options, disclosure: webGithubDisclosure(options.disclosure, { connections: () => githubConnections, providers: () => [...new Map(options.model.registry.list().map(r => [r.recipient.id, r.recipient] as const)).values()], work: { ...s15DefaultRefs.work }, interpretation: { ...s15DefaultRefs.interpretation }, now: options.clock?.now ?? Date.now }) }; const root = options.directory, manifestPath = join(root, 'composition.json'); - const storeNames = ['conversation.sqlite', 'policy.sqlite', 'model.sqlite', 'work.sqlite', 'vm.sqlite', 'team.sqlite', 'memory.sqlite', ...(options.disclosure ? ['disclosure.sqlite'] : []), ...(options.browser ? ['browser.sqlite'] : []), ...(options.github || webGithubEnabled ? ['github.sqlite'] : []), ...(options.files ? ['files.sqlite'] : []), ...(options.routines ? ['routines.sqlite'] : []), ...(options.notifications ? ['notifications.sqlite'] : [])]; + const storeNames = ['conversation.sqlite', 'policy.sqlite', 'model.sqlite', 'work.sqlite', 'vm.sqlite', 'team.sqlite', 'memory.sqlite', ...(options.disclosure ? ['disclosure.sqlite'] : []), ...(options.browser ? ['browser.sqlite'] : []), ...(options.github || webGithubEnabled ? ['github.sqlite'] : []), ...(options.files ? ['files.sqlite'] : []), ...(options.routines ? ['routines.sqlite'] : []), ...(options.notifications ? ['notifications.sqlite'] : []), ...(options.bots ? ['bots.sqlite'] : [])]; const manifest = { contract: 's15-composition/v1', installationId: options.installationId, stores: storeNames }; if (options.create) { // mkdir without recursive prevents accidentally accepting an existing S14/legacy/partial directory. @@ -145,7 +151,7 @@ export async function composeS15(options: S15CompositionOptions) { // earlier manifest, which an earlier release needs to start) and may hold an armed effect, or a complete fresh // store (creation is atomic). Only a store this boot created is removed if the boot fails. A store the manifest // already lists must still exist; a missing listed ledger always refuses. - const additive = [...(options.browser?.approved ? ['browser.sqlite'] : []), ...(webGithubEnabled ? ['github.sqlite'] : []), ...(options.files ? ['files.sqlite'] : []), ...(options.routines ? ['routines.sqlite'] : []), ...(options.notifications ? ['notifications.sqlite'] : [])]; + const additive = [...(options.browser?.approved ? ['browser.sqlite'] : []), ...(webGithubEnabled ? ['github.sqlite'] : []), ...(options.files ? ['files.sqlite'] : []), ...(options.routines ? ['routines.sqlite'] : []), ...(options.notifications ? ['notifications.sqlite'] : []), ...(options.bots ? ['bots.sqlite'] : [])]; let added: string[] = []; const created: string[] = []; if (!options.create) { @@ -205,6 +211,8 @@ export async function composeS15(options: S15CompositionOptions) { const githubIds = () => (gh?.repositories ?? []).map(r => r.resource.id); let policy!: S15PolicyService; let team!: TeamService; + // BOT16-01: created Bots' catalog identities and exclusions; constructed once POLICY exists. + let bots: BotDirectory | undefined; // Protected channel principals (WORK15-02 settlement). Each proof is a process-local object whose check // re-reads its durable binding on every POLICY owner verification; none is serializable, and a changed // owner generation refuses at once. @@ -233,6 +241,8 @@ export async function composeS15(options: S15CompositionOptions) { try { return policy.owner.authenticate(proof); } catch { return null; } }; const currentCatalogs: CatalogPort = { ...(options.catalogs.allowsModel ? { allowsModel: options.catalogs.allowsModel } : {}), current: (kind, id) => { + // A created, archived or restored Bot's identity is the directory's; an archived Bot is inactive everywhere. + const bot = kind === 'bot' ? bots?.catalog(id) : null; if (bot) return bot; // A Web-connected repository that was unselected or whose connection was removed is inactive whatever TEAM says. const connected = kind === 'resource' ? githubConnections?.resource(id) : kind === 'recipient' ? githubConnections?.recipient(id) : kind === 'lineage' ? files?.source(id) ?? webGithub?.current(id) ?? githubSourceRow(id) : null; if (connected && !connected.active) return connected; @@ -242,7 +252,7 @@ export async function composeS15(options: S15CompositionOptions) { if (connected && teamRow && !equal(teamRow.ref, connected.ref)) return connected; return teamRow ?? connected ?? options.catalogs.current(kind, id); }, - allows: (work, action) => options.catalogs.allows(work, action) && !!team && team.allowsWork(work) }; + allows: (work, action) => options.catalogs.allows(work, action) && !!team && team.allowsWork(work) && (!bots || bots.allows(work, action?.resources ?? work.request.resources)) }; const baseGithubAdapter = gh && githubStore ? createGithubActionAdapter({ store: githubStore, gateway: gh.gateway, limits: gh.limits, now: options.clock?.now ?? Date.now, policy: () => policy }) : undefined; const githubAdapter = baseGithubAdapter && disclosure && options.disclosure?.githubRoute ? disclosure.protectAdapter(baseGithubAdapter, options.disclosure.githubRoute) : baseGithubAdapter; policy = openPolicyService({ runtime: 's15', path: join(root, 'policy.sqlite'), create: options.create, installationId: options.installationId, @@ -264,6 +274,32 @@ export async function composeS15(options: S15CompositionOptions) { const memory = new MemoryService({ runtime: 's15', path: join(root, 'memory.sqlite'), create: options.create, installationId: options.installationId, team, retention: work => options.team?.retention(work) ?? null, ...(options.clock ? { now: options.clock.now } : {}) }); cleanup.push(() => memory.close()); const activeGuards = new Map void>(); const requireOwner = (actor: object) => { policy.owner.current(actor as OwnerActor); }; + if (options.bots) { + const unfinished = new Set(['queued', 'running', 'held', 'proposed']); + bots = new BotDirectory({ path: join(root, 'bots.sqlite'), installationId: options.installationId, create: options.create || created.includes('bots.sqlite'), now: clockNow, policy, + main: { bot: options.conversation.bot, selection: options.conversation.selection }, configured: id => options.catalogs.current('bot', id), + selections: owner => { const current = policy.owner.current(owner); return options.model.registry.list().filter(r => current?.selections.some(s => equal(s, r.selection))).map(r => ({ selection: structuredClone(r.selection), label: `${r.provider} · ${r.modelId} · ${r.selection.effort}` })); }, + resources: owner => { const current = policy.owner.current(owner); if (!current) return []; return team.resources(owner).resources.filter(item => item.active && current.resources.some(r => r.id === item.id)).map(item => ({ id: item.id, label: item.label, kind: item.kind })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0); }, + routes: () => options.model.registry.list().map(r => ({ selection: structuredClone(r.selection), provider: r.provider, modelId: r.modelId })), + conversations: { + started: conversationId => store.db.query('SELECT 1 FROM requests WHERE conversation_id=? LIMIT 1').get(conversationId) !== null, + unfinished: (owner, botId, conversationIds) => { + const ids = new Set(conversationIds); + return store.db.query<{ id: string; conversation_id: string; state: string; bot: string | null }, []>("SELECT id, conversation_id, state, json_extract(record_json,'$.delegation.botId') AS bot FROM requests WHERE state IN ('queued','running','held','proposed') ORDER BY rowid DESC LIMIT 1024").all() + .filter(row => unfinished.has(row.state) && (ids.has(row.conversation_id) || row.bot === botId)).map(row => { conversation.receipt(owner, row.id); return { requestId: row.id, conversationId: row.conversation_id }; }); + }, + recent: (owner, conversationIds) => conversationIds.flatMap(conversationId => { + const first = store.db.query<{ id: string }, [string]>('SELECT id FROM requests WHERE conversation_id=? ORDER BY rowid LIMIT 1').get(conversationId); if (!first) return []; + const latest = store.db.query<{ id: string }, [string]>('SELECT id FROM requests WHERE conversation_id=? ORDER BY rowid DESC LIMIT 1').get(conversationId)!; + const opening = conversation.view(owner, first.id), last = store.request(latest.id); + return [{ conversationId, firstMessage: opening.command.text, latestAt: last.receipt.receivedAt, assignment: !!opening.delegation }]; + }).sort((a, b) => b.latestAt - a.latestAt), + stop: (owner, requestId) => conversation.stop(owner, requestId), + }, + routines: { active: (owner, conversationIds) => routines ? routines.list(owner).routines.filter(item => item.routine.state === 'active' && conversationIds.includes(item.routine.draft.conversationId)).map(item => ({ id: item.routine.id, name: item.routine.draft.name, revision: item.routine.revision })) : [], + pause: (owner, routineId, revision) => { routines?.command(owner, { kind: 'routine.pause', requestId: `bot_pause_${routineId}_${revision}`, routineId, expectedRevision: revision }); } } }); + cleanup.push(() => bots!.close()); + } if (options.vm.local && !options.vm.managementVault) throw new AppError('vm_management_unavailable', 503); // Local working VMs: protected registry proxy on a controller-only socket, reached only by the QEMU guest-forward. const localVm = options.vm.local ? await startLocalVmControl(vmStore, { directory: join(root, 'vm-local'), ...options.vm.local }) : null; @@ -332,10 +368,12 @@ export async function composeS15(options: S15CompositionOptions) { }; const subjectIds = new Proxy([] as string[], { get: (_target, key) => { const ids = githubIds(), value = Reflect.get(ids, key); return typeof value === 'function' ? value.bind(ids) : value; } }); const conversationPolicy = createConversationPolicy(policy, { ...options.conversation, ...(gh ? { subjectResourceIds: options.github ? options.github.repositories.map(r => r.resource.id) : subjectIds } : {}), ownerGeneration: options.owner.generation, - ...(options.recipients ? { namedRecipients: (text: string) => options.recipients!.named(text, options.google?.recipients() ?? []), onApproved: (action: import('./policy').PreparedAction) => options.recipients!.remember(action) } : {}) }); + ...(options.recipients ? { namedRecipients: (text: string) => options.recipients!.named(text, options.google?.recipients() ?? []), onApproved: (action: import('./policy').PreparedAction) => options.recipients!.remember(action) } : {}), + ...(bots ? { bots: { resolve: (conversationId: string, selections: readonly import('./policy').Selection[]) => bots!.resolve(conversationId, selections) } } : {}) }); const effects = createEffectsConversationTools(policy, { ...(google ? { google: google.tools } : {}), ...(browser ? { browser, browserResourceId: options.browser!.resource.id } : {}) }); cleanup.push(() => effects.close()); conversationPolicy.dispatchAction = effects.dispatchAction; let conversation!: ConversationApplication; + let botTools: ReturnType | undefined; const work = openWorkService({ runtime: 's15', path: join(root, 'work.sqlite'), create: options.create, installationId: options.installationId, policy, conversation: { view: (owner, id) => conversation.view(owner, id) }, remainingCalls: workId => { const current = policy.control.policyForWork(workId), used = policy.control.reservations(workId).reduce((n, row) => n + row.upperBound.calls, 0); @@ -351,6 +389,7 @@ export async function composeS15(options: S15CompositionOptions) { // Repository ports follow the live selection; each stays scoped to its one repository resource. const ports = () => [vmPort, ...(github ? githubIds().map(id => ({ resourceId: id, port: githubPorts.get(id) ?? (githubPorts.set(id, createGithubConversationTools(github, id)), githubPorts.get(id)!) })) : []), ...(options.tools ?? [])]; conversation = new ConversationApplication(store, { policy: conversationPolicy, memory: createConversationMemory(memory, options.files?.memory && fileWorkflow && disclosure ? { ...fileWorkflow.memory, disclose(current, pins) { for (const pin of pins) disclosure!.read(current.actor, pin.source); } } : undefined), ...(fileWorkflow ? { files: fileWorkflow.port } : {}), nativeDirectory: join(root, 'native-chat'), routeAuth, ...(options.interpreter ? { interpreterModel: options.interpreter } : {}), ...(options.clock ? { clock: options.clock } : {}), + ...(bots ? { admitConversation: (conversationId: string) => bots!.admitConversation(conversationId), maxDelegatedRuns: () => { let sessions = 2; try { sessions = vmStore.registration(options.vm.id).maxSessions; } catch { /* Not enrolled yet. */ } return Math.max(1, Math.min(delegationLimits.installationConcurrent, sessions - 1)); } } : {}), ...(disclosure && modelDisclosure && options.disclosure ? { guardInterpretation(current: ConversationPolicyWork, payload: unknown, files: readonly import('./file-lineage').FileSourcePin[] = []) { const candidate = options.model.registry.current(current.authority.request.selection); if (!candidate || candidate.availability !== 'available') throw new AppError('route_unavailable'); const standing = policy.control.policyForWork(current.authority.id); @@ -369,6 +408,7 @@ export async function composeS15(options: S15CompositionOptions) { try { for (const registration of ports()) if (current.authority.request.resources.some(r => r.id === registration.resourceId)) opened.push(await registration.port.open(current, { ...input, guard: scopedGuard })); if (google || browser) opened.push(await effects.tools.open(current, { ...input, guard: scopedGuard })); + if (botTools) opened.push(await botTools.port.open(current, { ...input, guard: scopedGuard })); return { tools: opened.flatMap(p => p.tools).map(tool => ({ ...tool, definition: { ...tool.definition, async execute(value, call) { scopedGuard(); for (const resource of current.authority.request.resources) team.resource(context, resource.id, tool.access === 'read' ? 'read' : tool.definition.name === 'protected_browser' ? 'input' : 'write'); const result = await tool.definition.execute(value, call); scopedGuard(); return result; } } })), async cancel() { await Promise.all(opened.map(p => p.cancel())); }, async close() { await Promise.all(opened.map(p => p.close())); } }; } catch (error) { await Promise.allSettled(opened.map(p => p.close())); throw error; } } }, @@ -377,12 +417,13 @@ export async function composeS15(options: S15CompositionOptions) { if (disclosure && options.disclosure) { for (const source of options.disclosure.workSources(input.work.authority)) disclosure.read(input.work.actor, source); for (const route of options.model.registry.list()) modelDisclosure?.allows(input.work.authority, route); } activeGuards.set(input.work.authority.id, input.guard); try { const images = fileWorkflow?.images(input.work, input.request, options.files?.imageProbes ?? [], input.observeInput); const record = await models.runStep(input.work.actor, { stepId: 'conversation', difficulty: 'ordinary', text: input.request.intent.instruction, ...(images ? { images: images.references } : {}), - ownerDecisions: [canonical({ intent: input.request.intent, authority: input.request.authority }), ...(input.request.routine?.quiet ? [routineQuietDecision] : [])], sources: [], tools: input.definitions, ...(input.memorySection ? { memorySection: input.memorySection } : {}) }, input.signal, { beforeCall: input.countModelCall, steering: input.steering, ...(images ? { images: images.hooks } : {}) }); + ownerDecisions: [canonical({ intent: input.request.intent, authority: input.request.authority }), ...(input.request.routine?.quiet ? [routineQuietDecision] : []), ...(botTools?.ownerDecisions(input.work, input.request) ?? [])], sources: [], tools: input.definitions, ...(input.memorySection ? { memorySection: input.memorySection } : {}) }, input.signal, { beforeCall: () => { input.countModelCall(); botTools?.beforeChildSend(input.work, input.request); }, steering: input.steering, ...(images ? { images: images.hooks } : {}) }); input.guard(); if (record.state !== 'completed') throw new AppError(record.code ?? 'conversation_run_incomplete'); let text = ''; models.publish(input.work.actor, 'conversation', output => { text = output; }); if (Buffer.byteLength(text) > input.request.authority!.limits.outputBytes) throw new AppError('output_limit'); return { text, usage: null }; } finally { activeGuards.delete(input.work.authority.id); } } }); + if (bots) botTools = createBotTools({ conversation, directory: bots, policy, team, memory, ...(disclosure ? { disclosure } : {}), owner: snapshot => channelOwner({ ownerId: snapshot.id, generation: snapshot.generation }, () => true) }); // WORK15-02: the owner's own completed stall reply resumes its named responsibility, after restart too. const resumes = openResumeBindings({ store, conversation: { submit: (actor, command) => conversation.submit(actor, command) }, work: { project: (actor, id) => work.project(actor, id), resume: (actor, id, source) => { work.resume(actor, id, source); } }, @@ -459,7 +500,7 @@ export async function composeS15(options: S15CompositionOptions) { // One budget covers every metered route, so they must share one known currency; otherwise nothing is offered. const money = meteredCurrency(scope.selections); const budget = s15NeedsBudget(scope.selections) && entry && money.currency && entry.currency === money.currency ? s15MeteredBudget({ now, policyExpiresAt: now + lifetimeMs, currency: entry.currency, spendMinor: entry.spendMinor, periodId: entry.periodId, concurrency: options.defaultPolicy.concurrency }) : null; - const build = (value: import('./policy').OperatingBudget | null) => s15DefaultPolicy({ now, bot: scope.bot!, resources: scope.resources, selections: scope.selections, extraRecipients: scope.extraRecipients, budget: value, carry: current?.rules ?? [], lifetimeMs }); + const build = (value: import('./policy').OperatingBudget | null) => s15DefaultPolicy({ now, bot: scope.bot!, extraBots: bots?.createdRefs() ?? [], resources: scope.resources, selections: scope.selections, extraRecipients: scope.extraRecipients, budget: value, carry: current?.rules ?? [], lifetimeMs }); // Scope comparison keeps the installed budget unless the owner typed a new one: a metered policy that is // installed and unchanged is current, and a new amount is asked for only to change or renew it. return { scope, money, input: build(budget), scopeInput: build(budget ?? (s15NeedsBudget(scope.selections) ? current?.budget ?? null : null)), current }; @@ -588,7 +629,7 @@ export async function composeS15(options: S15CompositionOptions) { return (['owner_named_send', 'github_default'] as const).some(mode => proposed.rules.some(r => r.mode === mode) && !current.rules.some(r => r.mode === mode)); } catch { return false; } }; - const clientHandler = createS16ClientHandler(conversation, { origin: options.origin, cookieName, authentication, memory, policyUpdate, ...(routines ? { routines, wake: () => { void composed.drain().catch(() => {}); } } : {}), ...(notifications ? { notifications } : {}), ...(computers ? { computers } : {}), ...(files ? { files, imageAvailable: () => { const route = options.model.registry.current(options.conversation.selection); return !!route && route.availability === "available" && !!options.files?.imageProbes?.some(probe => probe.verdict === "pass" && probe.route === route.provider && probe.model === route.modelId); } } : {}) }); + const clientHandler = createS16ClientHandler(conversation, { origin: options.origin, cookieName, authentication, memory, policyUpdate, ...(routines ? { routines, wake: () => { void composed.drain().catch(() => {}); } } : {}), ...(notifications ? { notifications } : {}), ...(computers ? { computers } : {}), ...(bots ? { bots } : {}), ...(files ? { files, imageAvailable: () => { const route = options.model.registry.current(options.conversation.selection); return !!route && route.availability === "available" && !!options.files?.imageProbes?.some(probe => probe.verdict === "pass" && probe.route === route.provider && probe.model === route.modelId); } } : {}) }); const s16Web = await createS16WebHandler(clientHandler); const vmHandler = createVmApi(enrollment, async request => authenticate(request), { service: vm, ...(managers instanceof VmManagers ? { managers } : {}) }); const modelHandler = createModelProjectionHandler(models, authenticate), workHandler = createResponsibilityProjectionHandler(work, authenticate); @@ -606,7 +647,7 @@ export async function composeS15(options: S15CompositionOptions) { const dir = openSync(root, 'r'); try { fsyncSync(dir); } finally { closeSync(dir); } added = []; } - const composed = { policy, conversation, team, memory, files, disclosure, adoptionStatus, githubConnections, vm, workingVm, vmStore, enrollment, vmManagers: managers, vmPool: pool, provisioning, computers, computerScopes: scopes, models, work, github, browser, authentication, resumes, routines, + const composed = { policy, conversation, team, memory, files, bots, disclosure, adoptionStatus, githubConnections, vm, workingVm, vmStore, enrollment, vmManagers: managers, vmPool: pool, provisioning, computers, computerScopes: scopes, models, work, github, browser, authentication, resumes, routines, /** One protected scheduler wake; waiting records alone hold no VM/native session. Stall-reply resumes * settle after CHAT. */ drain(): Promise { if (closing) return Promise.resolve(); return draining ??= (async () => { try { reconcileVm(); try { routines?.tick(); routines?.dispatch(); } catch { /* Committed occurrences retry next wake. */ } await conversation.drain(); try { routines?.observe(); } catch { /* Observed next wake. */ } try { resumes.settle(); } catch { /* Pending bindings stay pending. */ } await github?.drain(); } finally { draining = null; } })(); }, diff --git a/src/s15/conversation/application.ts b/src/s15/conversation/application.ts index 2ab19a8d..1cb6ca4c 100644 --- a/src/s15/conversation/application.ts +++ b/src/s15/conversation/application.ts @@ -8,9 +8,9 @@ import { groundConversationIntent } from "./intent"; import { runConversation } from "./schway"; import { freezeInterpretation } from "./interpretation"; import { canonical, command, content, digest, exact, identity, integer } from "./validation"; -import type { ConversationAuthority, ConversationCommand, ConversationDecisionCommand, ConversationEvent, ConversationIntent, ConversationOptions, ConversationReceipt, ConversationRequest, ConversationRoutinePin, ConversationSteerCommand, ConversationSteerReceipt, ConversationSteerState, ConversationTools } from "./types"; +import type { ConversationAuthority, ConversationCommand, ConversationDecisionCommand, ConversationEvent, ConversationIntent, ConversationOptions, ConversationReceipt, ConversationDelegationPin, ConversationRequest, ConversationRoutinePin, ConversationSteerCommand, ConversationSteerReceipt, ConversationSteerState, ConversationTools } from "./types"; -const safeCodes = new Set(["material_ambiguity", "owner_start_required", "outside_scope", "authority_revoked", "stale_authority", "expired", "lineage_withdrawn", "selection_pinned", "budget_exhausted", "finite_budget_required", "cost_unknown", "route_unavailable", "capacity_busy", "approval_required", "recipient_approval_required", "merge_authority_required", "production_approval_required", "owner_direction_required", "policy_match_required", "adapter_unproven", "aborted", "tool_unavailable", "result_unavailable", "conversation_run_incomplete", "credential_content_refused", "authentication_required", "output_limit", "context_lost", "conversation_unavailable", "settlement_pending", "approval_reference_required", "stale_action", "already_claimed", "action_denied", "reconciliation_required", "payload_withdrawn", "read_bound_exhausted", "read_operation_unavailable", "recipient_unapproved", "classification_unknown", "recipient_unknown", "disclosure_changed", "disclosure_nonreleasable", "opaque_egress_unavailable", "memory_context_stale", "resource_busy", "stale_lease", +const safeCodes = new Set(["bot_archived", "bot_unavailable", "delegation_unavailable", "private_context", "context_lost", "parent_unavailable", "material_ambiguity", "owner_start_required", "outside_scope", "authority_revoked", "stale_authority", "expired", "lineage_withdrawn", "selection_pinned", "budget_exhausted", "finite_budget_required", "cost_unknown", "route_unavailable", "capacity_busy", "approval_required", "recipient_approval_required", "merge_authority_required", "production_approval_required", "owner_direction_required", "policy_match_required", "adapter_unproven", "aborted", "tool_unavailable", "result_unavailable", "conversation_run_incomplete", "credential_content_refused", "authentication_required", "output_limit", "context_lost", "conversation_unavailable", "settlement_pending", "approval_reference_required", "stale_action", "already_claimed", "action_denied", "reconciliation_required", "payload_withdrawn", "read_bound_exhausted", "read_operation_unavailable", "recipient_unapproved", "classification_unknown", "recipient_unknown", "disclosure_changed", "disclosure_nonreleasable", "opaque_egress_unavailable", "memory_context_stale", "resource_busy", "stale_lease", // MODEL15-02: honest route status and per-send dispatch outcomes. "route_needs_login", "route_store_unavailable", "route_store_invalid", "route_rejected", "route_integration_unavailable", "route_renewal_timeout", "route_choice_refused", "quota_exceeded", "auth_retry_refused", "images_unsupported", "images_unavailable", "file_validation_failed", "file_extraction_incomplete", "sanitizer_unavailable", "sanitizer_busy", "sanitizer_cleanup_failed", "effort_unsupported", "effort_unproven", "cost_bound_unknown", "route_control_refused", "dispatch_refused", "usage_invalid", "file_unavailable", "file_integrity_failed", "file_read_required", "artifact_required", "file_work_required"]); @@ -65,6 +65,11 @@ export class ConversationApplication { const next = row.state === "sent" ? "uncertain" : "unapplied"; this.store.setSteer(row.id, next); this.store.event(this.store.request(row.request_id), "steer", this.clock.now(), `steer_${next}`); } + // A delegated child that never started has no live owner registration and its parent is reconcile-only now. + for (const row of this.store.db.query<{ id: string }, []>("SELECT id FROM requests WHERE state='queued' AND json_type(record_json,'$.delegation')='object'").all()) { + const request = this.store.request(row.id); request.state = "blocked"; request.code = "context_lost"; request.revision++; + this.store.save(request); this.store.event(request, "stopped", this.clock.now(), request.code); + } for (const row of this.store.db.query<{ id: string }, []>("SELECT id FROM requests WHERE state='running'").all()) { const request = this.store.request(row.id); request.state = "reconcile_only"; request.code = "context_lost"; request.revision++; this.store.save(request); this.store.event(request, "needs_me", this.clock.now(), request.code); @@ -86,6 +91,49 @@ export class ConversationApplication { if (!value || typeof value !== "object" || (value as { continuation?: unknown }).continuation !== null) throw new AppError("invalid_data"); return this.#submit(actor, value, structuredClone(pin)); } + /** + * DELEG16-01 protected assignment ingress. Only the trusted delegation tool calls this, while the parent request is + * running. In one transaction it charges the child's finite slice to the parent's own model/tool counters (never + * refunded), checks the per-request and concurrent bounds and records the queued child with its immutable pin. The + * child inherits the parent's frozen purpose, resources and access, never more. `bind` records the child + * conversation's Bot before the child exists. Children are numbered per parent, so a repeated call never doubles. + */ + submitDelegation(actor: OwnerActor, input: { parentRequestId: string; kind: ConversationDelegationPin["kind"]; botId: string; fromBotId: string; task: string; sourceId: string | null; bind(conversationId: string): void }, bounds: { perRequest: number; concurrent: number }): ConversationReceipt { + this.options.policy.requireOwner(actor); identity(input.parentRequestId); identity(input.botId); identity(input.fromBotId); content(input.task, 16384); + if (input.sourceId !== null) identity(input.sourceId); + if (!["peer", "temporary"].includes(input.kind)) throw new AppError("invalid_data"); + const receipt = this.store.transaction(() => { + const parent = this.store.request(input.parentRequestId); this.owner(actor, parent); + if (this.#closing) throw new AppError("conversation_unavailable", 503); + if (parent.state !== "running" || parent.phase !== "work" || !parent.authority || !parent.intent.scope || parent.delegation || parent.code) throw new AppError("delegation_unavailable", 409); + const children = this.store.db.query<{ state: string }, [string]>("SELECT state FROM requests WHERE json_extract(record_json,'$.delegation.parentRequestId')=?").all(parent.receipt.rootRequestId); + if (children.length >= bounds.perRequest) throw new AppError("budget_exhausted", 409); + if (children.filter(child => child.state === "queued" || child.state === "running").length >= bounds.concurrent) throw new AppError("capacity_busy", 409); + const limits = parent.authority.limits, slice = { modelCalls: Math.max(2, Math.floor(limits.modelCalls / 3)), toolCalls: Math.max(1, Math.floor(limits.toolCalls / 3)) }; + // Shared finite budget: the parent keeps at least one model call to report; the slice is never refunded. + if (parent.modelCalls + slice.modelCalls >= limits.modelCalls || parent.toolCalls + slice.toolCalls > limits.toolCalls) throw new AppError("budget_exhausted", 409); + this.store.capacity(); + const ordinal = children.length + 1, key = digest(`s16.delegation.child.v1\n${parent.receipt.rootRequestId}\n${ordinal}`); + const childId = `dl_${key.slice(0, 40)}`, conversationId = `dc_${key.slice(0, 40)}`; + input.bind(conversationId); + this.options.admitConversation?.(conversationId); + parent.modelCalls += slice.modelCalls; parent.toolCalls += slice.toolCalls; parent.revision++; this.store.save(parent); + const scope = parent.intent.scope, command: ConversationCommand = { contract: "s15-conversation/v1", requestId: childId, conversationId, text: input.task, continuation: null }; + const pin: ConversationDelegationPin = { contract: "s16-delegation-run/v1", parentRequestId: parent.receipt.rootRequestId, parentConversationId: parent.receipt.conversationId, ordinal, kind: input.kind, botId: input.botId, fromBotId: input.fromBotId, purposeId: scope.purposeId, resourceIds: [...scope.resourceIds], access: scope.access, limits: slice, sourceId: input.sourceId }; + const intent: ConversationIntent = { kind: scope.access === "read" ? "inspect" : "execute", subject: null, instruction: input.task, scope: { purposeId: scope.purposeId, resourceIds: [...scope.resourceIds], access: scope.access } }; + const receipt: ConversationReceipt = { contract: "s15-conversation-receipt/v1", requestId: childId, rootRequestId: childId, conversationId, receivedAt: this.clock.now() }; + const request: ConversationRequest = { receipt, command, interpretationRequest: command, owner: { ...parent.owner }, revision: 1, intent, state: "queued", code: null, authority: null, phase: "work", interpretation: null, interpretationCalls: 0, modelCalls: 0, toolCalls: 0, result: null, usage: null, actions: [], decision: null, delegation: pin }; + this.store.db.query("INSERT INTO requests VALUES (?,?,?,?)").run(childId, conversationId, request.state, canonical(request)); + this.store.db.query("INSERT INTO commands VALUES (?,?,?,?,?)").run(childId, digest(`s16.delegation.command.v1\n${canonical({ command, pin })}`), childId, canonical(receipt), canonical(command)); + this.store.event(request, "received", this.clock.now()); + this.store.event(request, "admitted", this.clock.now()); + this.options.beforeCommit?.(); + return receipt; + }); + this.#owners.set(receipt.rootRequestId, { actor, commandId: receipt.requestId }); + this.#wakeDrain(); + return receipt; + } /** `decision` comes only from the exact one-press decision command (`decide`); typed text never decides. */ #submit(actor: OwnerActor, value: unknown, pin: ConversationRoutinePin | null, decision: OwnerDecisionInput | null = null): ConversationReceipt { this.options.policy.requireOwner(actor); command(value); @@ -99,6 +147,8 @@ export class ConversationApplication { return JSON.parse(prior.receipt_json) as ConversationReceipt; } if (this.#closing) throw new AppError("conversation_unavailable", 503); + // BOT16-01: an archived Bot's conversation accepts nothing new (messages, clarifications, routine runs). + this.options.admitConversation?.(input.conversationId); this.store.capacity(); let intent = groundConversationIntent(input.text, { fileOutput: input.contract === "s16-conversation/v2" }); let request: ConversationRequest; @@ -390,6 +440,10 @@ export class ConversationApplication { this.store.save(request); this.store.event(request, "stopped", this.clock.now(), "aborted"); }); this.#running.get(requestId)?.abort(); + // DELEG16-01: stopping the originating request stops its unfinished children; a child's stop leaves its siblings. + for (const child of this.store.db.query<{ id: string }, [string]>("SELECT id FROM requests WHERE json_extract(record_json,'$.delegation.parentRequestId')=? AND state IN ('queued','running','held','proposed')").all(requestId)) { + try { this.stop(actor, child.id); } catch { /* Settled meanwhile. */ } + } } /** * Wakes the local durable queue drain. Concurrent callers share it; a claim is committed before I/O. Up to @@ -400,16 +454,21 @@ export class ConversationApplication { if (this.#draining) { this.#wakeDrain(); return this.#draining; } const done = Promise.withResolvers(); this.#draining = done.promise; const max = Math.max(1, Math.min(this.options.maxConcurrentRuns ?? 2, 8)); + const delegatedPool = () => { const value = typeof this.options.maxDelegatedRuns === "function" ? this.options.maxDelegatedRuns() : this.options.maxDelegatedRuns ?? 3; return Math.max(1, Math.min(Number.isSafeInteger(value) ? value : 1, 8)); }; void (async () => { - const active = new Map>(); + const active = new Map>(), children = new Set(); let failure: unknown = null; try { while (!this.#closing) { const busy = new Set([...active.keys()].map(id => this.store.request(id).receipt.conversationId)); - // Only process-local authenticated registrations are runnable after restart. - const id = active.size >= max ? undefined : [...this.#owners.keys()].find(id => !active.has(id) && this.store.request(id).state === "queued" && !busy.has(this.store.request(id).receipt.conversationId)); + // Only process-local authenticated registrations are runnable after restart. Delegated children use their own + // finite pool: a parent waiting on its children holds an ordinary slot and must not starve them. + const delegated = delegatedPool(); + const runnable = (id: string) => { const request = this.store.request(id); return !active.has(id) && request.state === "queued" && !busy.has(request.receipt.conversationId) && (request.delegation ? children.size < delegated : active.size - children.size < max); }; + const id = [...this.#owners.keys()].find(runnable); if (id) { - const run = (this.store.request(id).phase === "decision" ? this.runDecision(id) : this.run(id)).catch(error => { failure ??= error; }).finally(() => active.delete(id)); + if (this.store.request(id).delegation) children.add(id); + const run = (this.store.request(id).phase === "decision" ? this.runDecision(id) : this.run(id)).catch(error => { failure ??= error; }).finally(() => { active.delete(id); children.delete(id); }); active.set(id, run); continue; } if (!active.size) break; @@ -468,7 +527,11 @@ export class ConversationApplication { const cancelTools = () => { if (tools && !cancel) cancel = Promise.resolve().then(() => tools!.cancel()).catch(() => {}); }; abort.signal.addEventListener("abort", cancelTools, { once: true }); try { - let request = this.store.transaction(() => { + let request: ConversationRequest; + // DELEG16-01: a delegated child has no interpretation call; its pin already fixes the narrowed scope. + if (this.store.request(id).delegation) request = this.store.transaction(() => this.admitDelegation(id, registration?.actor)); + else { + request = this.store.transaction(() => { const request = this.store.request(id); if (request.state !== "queued" || !request.interpretation) throw new AppError("already_claimed", 409); const actor = registration?.actor; if (!actor) throw new AppError("authentication_required", 401); @@ -520,14 +583,16 @@ export class ConversationApplication { this.store.event(current, current.state === "running" ? "admitted" : "needs_me", this.clock.now(), current.code); this.options.beforeCommit?.(); return current; }); + } if (request.state !== "running") return; work = this.options.policy.open(request.authority!); guard("model"); clearTimeout(timer); startedAt = this.clock.now(); timer = setTimeout(() => abort.abort(), request.authority!.limits.activeMs); // Persist continuity dependency before MEM can retain/withdraw anything. A later boot without // that protected service must refuse rather than replay raw owner/history or publish old data. - if (this.options.memory && (!request.fileInputs || request.fileMemory)) this.store.transaction(() => { const current = this.store.request(id); current.memory = { retained: [] }; this.store.save(current); }); + if (this.options.memory && !request.delegation && (!request.fileInputs || request.fileMemory)) this.store.transaction(() => { const current = this.store.request(id); current.memory = { retained: [] }; this.store.save(current); }); const ingress = this.store.db.query<{ receipt_json: string }, [string]>("SELECT receipt_json FROM commands WHERE id=?").get(request.interpretationRequest.requestId)!; - const memory = request.fileInputs && !request.fileMemory ? undefined : this.options.memory?.prepare(work!, request, { receivedAt: (JSON.parse(ingress.receipt_json) as ConversationReceipt).receivedAt }); + // A delegated child works from its assignment only: no sourced memory is retrieved or retained for it. + const memory = request.delegation || request.fileInputs && !request.fileMemory ? undefined : this.options.memory?.prepare(work!, request, { receivedAt: (JSON.parse(ingress.receipt_json) as ConversationReceipt).receivedAt }); if (memory) { this.store.transaction(() => { const current = this.store.request(id); current.memory = { retained: memory.retained }; this.store.save(current); }); request = this.store.request(id); guard("model"); @@ -648,6 +713,31 @@ export class ConversationApplication { if (this.#owners.get(id)?.commandId === registration?.commandId) this.#owners.delete(id); } } + /** Claims one queued delegated child and admits it under current policy inside its pin; a refusal blocks it. */ + private admitDelegation(id: string, actor: OwnerActor | undefined): ConversationRequest { + const current = this.store.request(id); + if (current.state !== "queued" || !current.delegation) throw new AppError("already_claimed", 409); + if (!actor) throw new AppError("authentication_required", 401); + this.owner(actor, current); + const pin = current.delegation; + let code: string | null = null; + try { + if (this.store.request(pin.parentRequestId).state !== "running") throw new AppError("parent_unavailable", 409); + this.options.admitConversation?.(current.receipt.conversationId); + const decision = this.options.policy.admit(actor, current.command, current.intent, { imageInputs: 0, ownerMessage: false, delegation: pin.limits }); + if (decision.state !== "admitted") code = safeCodes.has(decision.code) ? decision.code : "policy_match_required"; + else { + const admitted = authority(decision.authority); + if (admitted.purposeId !== pin.purposeId || admitted.resources.some(resource => !pin.resourceIds.includes(resource))) throw new AppError("outside_scope"); + current.authority = admitted; current.state = "running"; + } + } catch (error) { code = safeCode(error); } + if (code) { current.state = "blocked"; current.code = code; } + current.revision++; this.store.save(current); + this.store.event(current, current.state === "running" ? "progress" : "stopped", this.clock.now(), current.code); + this.options.beforeCommit?.(); + return current; + } private interpretationContext(request: ConversationRequest, work: WorkContext): { messages: { role: "owner" | "assistant"; text: string }[]; fragments: { id: string; text: string }[] } { const context: { role: "owner" | "assistant"; text: string }[] = []; let bytes = 0; const fragments: { id: string; text: string }[] = []; diff --git a/src/s15/conversation/owner-view.ts b/src/s15/conversation/owner-view.ts index c80a5bc5..0a1901ac 100644 --- a/src/s15/conversation/owner-view.ts +++ b/src/s15/conversation/owner-view.ts @@ -31,7 +31,13 @@ export interface ConversationOwnerRow { steers: ConversationSteer[]; /** Owner decision this message recorded on one exact action revision, with its dispatch observation when known. */ decision: { action: ActionRef; decision: "approve" | "deny"; outcome: Observation["outcome"] | null } | null; + /** DELEG16-01: on a delegated child, its originating request and assignee. `ownerMessage` then holds the assigning Bot's task text. */ + delegation?: { parentRequestId: string; parentConversationId: string; kind: "peer" | "temporary"; botId: string; fromBotId: string }; + /** DELEG16-01: assignments this request handed to peers or temporary workers, in order. States are the children's own. */ + children?: ConversationChild[]; } +/** One delegated child of a request. The task is the assigning Bot's text; the result is read in the child conversation. */ +export interface ConversationChild { requestId: string; conversationId: string; kind: "peer" | "temporary"; botId: string; task: string; state: ConversationOwnerRow["state"]; code: string | null } /** Safe hold entry with the real POLICY owner-only exact prepared payload projection. */ export interface ConversationNeedsYou { id: string; requestId: string; code: string; kind: "question" | "action" | "reconciliation"; slip: ActionSlip | null } /** Bounded read-only owner projection; no polling/reconnect action can admit work or approve a slip. */ @@ -86,9 +92,19 @@ function ownerRow(app: ConversationApplication, actor: reply: request.interpretationRequest.requestId === request.command.requestId ? null : request.interpretationRequest.text, ...(request.routine ? { routine: { routineId: request.routine.routineId, occurrenceId: request.routine.occurrenceId, quiet: request.routine.quiet } } : {}), steers: app.store.steers(id), + ...(request.delegation ? { delegation: { parentRequestId: request.delegation.parentRequestId, parentConversationId: request.delegation.parentConversationId, kind: request.delegation.kind, botId: request.delegation.botId, fromBotId: request.delegation.fromBotId } } : {}), + ...(() => { const children = childRows(app, id); return children.length ? { children } : {}; })(), decision: request.decision ? { action: { ...request.decision.action }, decision: request.decision.decision, outcome: request.decision.observation?.outcome ?? null } : null }; } +/** Children read from protected state only; a child's text and state never come from the parent's model output. */ +function childRows(app: ConversationApplication, parentId: string): ConversationChild[] { + return app.store.db.query<{ id: string }, [string]>("SELECT id FROM requests WHERE json_extract(record_json,'$.delegation.parentRequestId')=? ORDER BY json_extract(record_json,'$.delegation.ordinal') LIMIT 16").all(parentId).map(({ id }) => { + const child = app.store.request(id), pin = child.delegation!; + return { requestId: id, conversationId: child.receipt.conversationId, kind: pin.kind, botId: pin.botId, task: child.command.text, state: state(child), code: child.code }; + }); +} + /** One guarded owner row independent of the bounded conversation window; never exposes the raw journal. */ export function conversationRequestView(app: ConversationApplication, actor: Actor, requestId: string): ConversationOwnerRow { return ownerRow(app, actor, requestId); @@ -109,7 +125,8 @@ export function conversationPendingNeeds(app: ConversationApplicati /** Recent conversations for the authenticated owner, newest first. Reading starts no model, tool or queue. */ export function conversationList(app: ConversationApplication, actor: Actor, limit = 32): ConversationListEntry[] { const owner = app.options.policy.requireOwner(actor); - const rows = app.store.db.query<{ conversation_id: string; latest: number }, [number]>("SELECT conversation_id, max(rowid) AS latest FROM requests GROUP BY conversation_id ORDER BY latest DESC LIMIT ?").all(Math.max(1, Math.min(limit, 64))); + // Delegated child conversations open from their parent's card, never as top-level chats. + const rows = app.store.db.query<{ conversation_id: string; latest: number }, [number]>("SELECT conversation_id, max(rowid) AS latest FROM requests WHERE json_type(record_json,'$.delegation') IS NULL GROUP BY conversation_id ORDER BY latest DESC LIMIT ?").all(Math.max(1, Math.min(limit, 64))); return rows.flatMap(row => { const first = app.store.db.query<{ id: string }, [string]>("SELECT id FROM requests WHERE conversation_id=? ORDER BY rowid LIMIT 1").get(row.conversation_id)!; const latest = app.store.db.query<{ id: string }, [number]>("SELECT id FROM requests WHERE rowid=?").get(row.latest)!; diff --git a/src/s15/conversation/policy-port.ts b/src/s15/conversation/policy-port.ts index 1423de24..387137af 100644 --- a/src/s15/conversation/policy-port.ts +++ b/src/s15/conversation/policy-port.ts @@ -34,11 +34,19 @@ export function createConversationPolicy(host: S15PolicyService, input: { /** AUTH16-01 (S16-D07 g): called after POLICY recorded an exact owner approval, with the approved revision. A failure * here never undoes or blocks that decision. */ onApproved?: (action: PreparedAction) => void; + /** + * BOT16-01 (ADR 0088): the conversation's persistent Bot, its route and the team resources it may not use. Absent, + * every conversation talks to `bot` with `selection`. An archived Bot throws, refusing interpretation and admission. + */ + bots?: { resolve(conversationId: string, selections: readonly Selection[]): { bot: Ref; selection: Selection; excluded: readonly string[] } }; }): ConversationPolicy { const config = structuredClone({ ownerId: input.ownerId, bot: input.bot, selection: input.selection, route: input.route, limits: input.limits, modelCallUpperBound: input.modelCallUpperBound }); const invoke = (call: () => T): T => { try { return call(); } catch (error) { throw mapped(error); } }; const current = (actor: OwnerActor) => invoke(() => { const policy = host.owner.current(actor); if (!policy) throw new AppError("policy_match_required"); return policy; }); - const snapshot = (work: WorkAuthority, interpretation = false): ConversationAuthority => ({ id: work.id, purposeId: work.request.purpose.id, policyRevision: work.policyRevision, resources: work.request.resources.map(resource => resource.id), route: { ...config.route }, limits: { ...config.limits, ...(interpretation ? { modelCalls: 1, toolCalls: 0, activeMs: Math.min(config.limits.activeMs, 30000) } : {}) } }); + // DELEG16-01: a delegated child runs inside the slice its parent was charged for, never above the configured limits. + const limitsFor = (slice?: { modelCalls: number; toolCalls: number }) => ({ ...config.limits, ...(slice ? { modelCalls: Math.min(config.limits.modelCalls, slice.modelCalls), toolCalls: Math.min(config.limits.toolCalls, slice.toolCalls) } : {}) }); + const snapshot = (work: WorkAuthority, interpretation = false, slice?: { modelCalls: number; toolCalls: number }): ConversationAuthority => ({ id: work.id, purposeId: work.request.purpose.id, policyRevision: work.policyRevision, resources: work.request.resources.map(resource => resource.id), route: { ...config.route }, limits: { ...limitsFor(slice), ...(interpretation ? { modelCalls: 1, toolCalls: 0, activeMs: Math.min(config.limits.activeMs, 30000) } : {}) } }); + const resolve = (conversationId: string, policy: { selections: Selection[] }) => input.bots ? invoke(() => input.bots!.resolve(conversationId, policy.selections)) : { bot: config.bot, selection: config.selection, excluded: [] as readonly string[] }; return { // Reading the owner's own conversations needs an authenticated owner, not a standing policy: a fresh install // with no policy yet must still render its pages (POLICY15-02). Admission keeps requiring the policy. @@ -46,14 +54,16 @@ export function createConversationPolicy(host: S15PolicyService, input: { prepareInterpretation(actor, request) { try { // The standing expiry is stable across an interrupted local ingress commit; local phase deadlines stay finite. - const policy = current(actor), prepared = invoke(() => host.owner.prepareInterpretation(actor, { requestId: `ci_${digest(request.requestId)}`, selection: config.selection, bot: config.bot, expiresAt: Math.min(policy.expiresAt, policy.budget?.expiresAt ?? policy.expiresAt) })); - return { state: "admitted", authority: snapshot(prepared.authority, true), purposes: prepared.purposes.map(purpose => purpose.id), resources: policy.resources.map(resource => resource.id) }; + const policy = current(actor), bot = resolve(request.conversationId, policy); + const prepared = invoke(() => host.owner.prepareInterpretation(actor, { requestId: `ci_${digest(request.requestId)}`, selection: config.selection, bot: bot.bot, expiresAt: Math.min(policy.expiresAt, policy.budget?.expiresAt ?? policy.expiresAt) })); + return { state: "admitted", authority: snapshot(prepared.authority, true), purposes: prepared.purposes.map(purpose => purpose.id), resources: policy.resources.map(resource => resource.id).filter(id => !bot.excluded.includes(id)) }; } catch (error) { return { state: "held", code: mapped(error).code }; } }, admit(actor, request, intent, reads) { try { - const policy = current(actor), scope = intent.scope; + const policy = current(actor), scope = intent.scope, bot = resolve(request.conversationId, policy); if (!scope) throw new AppError("outside_scope"); + if (scope.resourceIds.some(id => bot.excluded.includes(id))) throw new AppError("outside_scope"); const purpose = policy.purposes.find(purpose => purpose.id === scope.purposeId); const resources = scope.resourceIds.map(id => { const resource = policy.resources.find(resource => resource.id === id); if (!resource) throw new AppError("outside_scope"); return resource; }); if (!purpose) return { state: "proposed", code: "outside_scope" }; @@ -63,19 +73,21 @@ export function createConversationPolicy(host: S15PolicyService, input: { if (!Number.isSafeInteger(images) || images < 0 || images > 8) throw new AppError("outside_scope"); // Every model send can also read each selected image; this requests finite capacity only. // Current POLICY and the owner's read ceiling still authorize every physical read. - const readLimit = Math.min(config.limits.toolCalls + images * config.limits.modelCalls, policy.budget?.ceiling.reads ?? host.control.subscriptionLimits()?.tools?.reads ?? 0); + const limits = limitsFor(reads?.delegation); + const readLimit = Math.min(limits.toolCalls + images * limits.modelCalls, policy.budget?.ceiling.reads ?? host.control.subscriptionLimits()?.tools?.reads ?? 0); // S16-D07 b: only the owner's own message names recipients, and only under a policy the owner accepted with that rule. const named = reads?.ownerMessage === true && input.namedRecipients && policy.rules.some(rule => rule.mode === "owner_named_send") ? invoke(() => input.namedRecipients!(request.text)) : []; const recipients = [...policy.recipients, ...named.filter(n => !policy.recipients.some(r => r.id === n.id))]; - const admitted = invoke(() => host.owner.admit(actor, { requestId: `cw_${digest(request.requestId)}`, intent: intent.kind === "execute" ? "execute" : "inspect", access: scope.access, purpose, bot: config.bot, resources, recipients, ...(named.length ? { named } : {}), selection: config.selection, selectionPin: config.selection, expiresAt: Math.min(policy.expiresAt, policy.budget?.expiresAt ?? policy.expiresAt), readLimit, subject: intent.subject ? { resource: subjects[0]!, issue: Number(intent.subject.slice(6)) } : null })); + const admitted = invoke(() => host.owner.admit(actor, { requestId: `cw_${digest(request.requestId)}`, intent: intent.kind === "execute" ? "execute" : "inspect", access: scope.access, purpose, bot: bot.bot, resources, recipients, ...(named.length ? { named } : {}), selection: bot.selection, selectionPin: bot.selection, expiresAt: Math.min(policy.expiresAt, policy.budget?.expiresAt ?? policy.expiresAt), readLimit, subject: intent.subject ? { resource: subjects[0]!, issue: Number(intent.subject.slice(6)) } : null })); if (!admitted.work) throw new AppError("outside_scope"); - return { state: "admitted", authority: snapshot(admitted.work) }; + return { state: "admitted", authority: snapshot(admitted.work, false, reads?.delegation) }; } catch (error) { const code = mapped(error).code; return { state: code === "outside_scope" ? "proposed" : "held", code }; } }, open(reference) { return invoke(() => { const actor = host.control.bindWork(reference.id), work = host.work.guard(actor); - if (work.policyRevision !== reference.policyRevision || canonical(work.request.selection) !== canonical(config.selection) || work.request.purpose.id !== reference.purposeId || canonical(work.request.resources.map(resource => resource.id)) !== canonical(reference.resources)) throw new AppError("stale_authority"); + // With persistent Bots each work keeps the route its Bot had at admission; POLICY pins it (`selectionPin`). + if (work.policyRevision !== reference.policyRevision || !input.bots && canonical(work.request.selection) !== canonical(config.selection) || work.request.purpose.id !== reference.purposeId || canonical(work.request.resources.map(resource => resource.id)) !== canonical(reference.resources)) throw new AppError("stale_authority"); return { actor, authority: work }; }); }, @@ -91,7 +103,7 @@ export function createConversationPolicy(host: S15PolicyService, input: { dispatchAction(reference, action) { return host.work.dispatch(invoke(() => host.control.bindWork(reference.id)), action).catch(error => { throw mapped(error); }); }, stopWork(actor, reference, requestId) { invoke(() => host.owner.revoke(actor, `cs_${digest(requestId)}`, { kind: "work", id: reference.id })); }, reserveModel(work, slot) { - const reservation = invoke(() => host.work.reserveModelCall(work.actor, { requestId: `cm_${digest(slot)}`, selection: config.selection, upperBound: config.modelCallUpperBound })); + const reservation = invoke(() => host.work.reserveModelCall(work.actor, { requestId: `cm_${digest(slot)}`, selection: input.bots ? work.authority.request.selection : config.selection, upperBound: config.modelCallUpperBound })); invoke(() => host.work.guardReservation(work.actor, reservation.id)); let settled = false; return { settle() { diff --git a/src/s15/conversation/types.ts b/src/s15/conversation/types.ts index 695bf681..e8a9ca2c 100644 --- a/src/s15/conversation/types.ts +++ b/src/s15/conversation/types.ts @@ -62,6 +62,22 @@ export interface ConversationScope { purposeId: string; resourceIds: string[]; a * admission still runs under current standing policy. */ export interface ConversationRoutinePin { contract: "s16-routine-run/v1"; routineId: string; occurrenceId: string; purposeId: string; resourceIds: string[]; access: ConversationScope["access"]; quiet: boolean } +/** + * `s16-delegation-run/v1` (DELEG16-01, ADR 0088): protected pin of one Bot-to-Bot assignment, set only by + * `ConversationApplication.submitDelegation`. The child runs inside its parent's frozen purpose, resources and access + * ceiling with the finite slice charged to the parent; the task text was written by the assigning Bot, so it is data + * inside that scope and never decides a held action. A child has no interpretation call, no memory and no further + * delegation. `peer` runs as another enabled persistent Bot; `temporary` runs as the assigning Bot and is not kept. + */ +export interface ConversationDelegationPin { + contract: "s16-delegation-run/v1"; + parentRequestId: string; parentConversationId: string; ordinal: number; + kind: "peer" | "temporary"; botId: string; fromBotId: string; + purposeId: string; resourceIds: string[]; access: ConversationScope["access"]; + limits: { modelCalls: number; toolCalls: number }; + /** TEAM source holding the task text and its privacy ancestry; handed off to the child before its model sends. */ + sourceId: string | null; +} /** Exact current owner identity returned by the protected authentication service, never by a client body. */ export interface ConversationOwner { id: string; generation: number } /** Persisted authority reference is data. Only the owning policy service can open a live work context. */ @@ -82,7 +98,8 @@ export interface ConversationPolicy { | { state: "held"; code: string }; /** Synchronous idempotent admission under current policy. Reads is a trusted accepted-image count * for capacity calculation, not model input, new permission or an override of the owner ceiling. */ - admit(actor: OwnerActor, request: ConversationCommand, intent: ConversationIntent, reads?: { imageInputs: number; /** AUTH16-01: true only for a live owner message (not a routine occurrence). */ ownerMessage?: boolean }): + admit(actor: OwnerActor, request: ConversationCommand, intent: ConversationIntent, reads?: { imageInputs: number; /** AUTH16-01: true only for a live owner message (not a routine occurrence). */ ownerMessage?: boolean; + /** DELEG16-01: the finite slice a delegated child was charged to its parent; it caps the child's own limits. */ delegation?: { modelCalls: number; toolCalls: number } }): | { state: "admitted"; authority: ConversationAuthority } | { state: "held" | "proposed"; code: string }; /** Reopens work authority only; it cannot reconstruct a claimed model/tool dispatch after process loss. */ @@ -151,6 +168,8 @@ export interface ConversationRequest { artifacts?: FileArtifact[]; /** Present only on a routine occurrence; the original pin is immutable for the request's lifetime. */ routine?: ConversationRoutinePin; + /** Present only on a delegated child; immutable for the request's lifetime. */ + delegation?: ConversationDelegationPin; modelCalls: number; toolCalls: number; result: string | null; usage: HostRunUsage | null; /** Usage evidence of the latest interpretation run, kept on failure too. Absent before MODEL15-02. */ interpretationUsage?: HostRunUsage; @@ -194,6 +213,11 @@ export interface ConversationOptions { runWork?: (input: { work: WorkContext; request: ConversationRequest; definitions: readonly ToolDefinition[]; memorySection: string | undefined; signal: AbortSignal; guard(): void; countModelCall(): void; observeInput(evidence: ConversationEvidence): void; steering: ModelStepSteering }) => Promise<{ text: string; usage: HostRunUsage | null }>; /** Most requests that run at once (default 2); one conversation never runs two requests at the same time. */ maxConcurrentRuns?: number; + /** DELEG16-01: delegated children run in their own finite pool (default 3) so a waiting parent cannot starve them. + * A function is read on every scheduling pass (composition sizes it to the working computer's free sessions). */ + maxDelegatedRuns?: number | (() => number); + /** BOT16-01 ingress fence: throws when the conversation's Bot is archived or unavailable. Called for every new request. */ + admitConversation?: (conversationId: string) => void; /** Commit-fault seam for deterministic rollback tests; production leaves it absent. */ beforeCommit?: () => void; } diff --git a/src/s15/main.ts b/src/s15/main.ts index b46fa2b7..930abbb5 100644 --- a/src/s15/main.ts +++ b/src/s15/main.ts @@ -124,6 +124,8 @@ export async function runS15(args: string[], env: NodeJS.ProcessEnv = process.en ...(config.notifications ? { notifications: {} } : {}), // TEAM16-01: owner decision 2026-11-07, a working computer with no open session sleeps after 30 minutes. ...(config.computers ? { computers: { idleStopMs: s16ComputerIdleStopMs } } : {}), + // BOT16-01/DELEG16-01: persistent Bots the owner accepts and bounded Bot-to-Bot assignments. + ...(config.bots ? { bots: {} } : {}), ...(config.files ? { files: { ...(typeof config.files === 'object' ? { parserRuntime: binaryFileRuntime(config.files, config.directory, env), imageProbes: config.files.imageProbes } : {}), ...(config.fileMemory ? { memory: true } : {}) } } : {}), recipients, owner: { authenticate: token => { const auth = ownerApp.authenticate(token); return { proof: auth.actor, csrf: auth.csrf }; }, generation, verifyOwner: requireOwner, verifyCsrf: (expected, supplied) => ownerApp.verifyCsrf(expected, supplied) }, catalogs, diff --git a/src/s15/memory/service.ts b/src/s15/memory/service.ts index 54c57363..d7ec1345 100644 --- a/src/s15/memory/service.ts +++ b/src/s15/memory/service.ts @@ -218,6 +218,14 @@ export class MemoryService { }); } /** Derived content read always rechecks original claim/resource/private scope before returning bytes. */ + /** + * DELEG16-01: TEAM ancestry of one request's context packet (every claim it included and its file pins), so text an + * assigning Bot writes from that context keeps the same privacy labels when it is handed to another Bot. + */ + contextLineage(context: TeamContext, packetId: string): { sourceIds: string[]; private: boolean; filePins: FileSourcePin[] } { + const derivative = this.derivative(context, packetId), claims = derivative.claimIds.map(id => this.claim(id)); + return { sourceIds: [...new Set(claims.flatMap(claim => claim.provenance.teamSourceId ?? []))], private: claims.some(claim => claim.provenance.scope.kind === "bot"), filePins: filePins(derivative.filePins) }; + } derivative(context: TeamContext, derivativeId: string): MemoryDerivative { this.guard(context, derivativeId); return JSON.parse(this.store.db.query<{ record_json: string }, [string]>("SELECT record_json FROM derivatives WHERE id=?").get(derivativeId)!.record_json) as MemoryDerivative; } /** Ordinary CHAT history/replay cannot reintroduce forgotten/corrected source or retired summaries. */ allowHistory(requestId: string, text: string): boolean { diff --git a/src/s15/policy/defaults.ts b/src/s15/policy/defaults.ts index fa226c13..591833b4 100644 --- a/src/s15/policy/defaults.ts +++ b/src/s15/policy/defaults.ts @@ -60,6 +60,8 @@ export const s16GithubDefaultOperations: readonly string[] = Object.freeze(['mer */ export function s15DefaultPolicy(input: { now: number; bot: Ref; resources: readonly DefaultResource[]; selections: readonly DefaultSelection[]; + /** BOT16-01: further persistent Bots the owner accepted; each gets the main Bot's team defaults (same rules). */ + extraBots?: readonly Ref[]; extraRecipients?: readonly Ref[]; budget: OperatingBudget | null; carry?: readonly Rule[]; lifetimeMs?: number; }): PolicyInput | null { if (!input.selections.length || !input.resources.length) return null; @@ -71,16 +73,17 @@ export function s15DefaultPolicy(input: { const adapters = [...new Set(input.resources.flatMap(r => r.adapters.map(a => a.id)))].sort(); const operations = [...new Set(input.resources.flatMap(r => r.adapters.flatMap(a => a.operations)))].sort(); const purposes = [structuredClone(s15DefaultRefs.work), structuredClone(s15DefaultRefs.interpretation)]; - const ordinary: Rule = { id: 'owner_requested_work', mode: 'ordinary', purposes: [structuredClone(s15DefaultRefs.work)], bots: [structuredClone(input.bot)], resources, recipients, selections, + const bots = unique([input.bot, ...(input.extraBots ?? [])]).map(b => structuredClone(b)); + const ordinary: Rule = { id: 'owner_requested_work', mode: 'ordinary', purposes: [structuredClone(s15DefaultRefs.work)], bots: bots.map(b => structuredClone(b)), resources, recipients, selections, adapters: adapters.length ? adapters : ['working_vm'], operations: operations.length ? operations : ['vm_write'], classes: [...s15DefaultRuleClasses], expiresAt, recurrence: null }; // AUTH16-01 (S16-D07 b, d): owner-named sends and GitHub work after required checks run without approval. const named: Rule = { ...structuredClone(ordinary), id: s16DefaultRuleIds.namedSends, mode: 'owner_named_send', recipients: recipients.map(r => structuredClone(r)), classes: ['ordinary_edit', 'communication'] }; const repositories = input.resources.filter(r => r.kind === 'repository' && r.adapters.some(a => a.id === 's15_github')); const githubOps = [...new Set(repositories.flatMap(r => r.adapters.filter(a => a.id === 's15_github').flatMap(a => a.operations)))].filter(op => s16GithubDefaultOperations.includes(op)).sort(); const rules: Rule[] = [ordinary, named]; - if (repositories.length && githubOps.length) rules.push({ id: s16DefaultRuleIds.github, mode: 'github_default', purposes: [structuredClone(s15DefaultRefs.work)], bots: [structuredClone(input.bot)], resources: unique(repositories.map(r => structuredClone(r.ref))), recipients: [], selections: selections.map(x => structuredClone(x)), + if (repositories.length && githubOps.length) rules.push({ id: s16DefaultRuleIds.github, mode: 'github_default', purposes: [structuredClone(s15DefaultRefs.work)], bots: bots.map(b => structuredClone(b)), resources: unique(repositories.map(r => structuredClone(r.ref))), recipients: [], selections: selections.map(x => structuredClone(x)), adapters: ['s15_github'], operations: githubOps, classes: ['communication', 'github_merge', 'github_publish'], expiresAt, recurrence: null }); - const policy: PolicyInput = { purposes, interpretationPurpose: structuredClone(s15DefaultRefs.interpretation), bots: [structuredClone(input.bot)], resources, recipients, selections, + const policy: PolicyInput = { purposes, interpretationPurpose: structuredClone(s15DefaultRefs.interpretation), bots: bots.map(b => structuredClone(b)), resources, recipients, selections, budget: input.budget ? structuredClone(input.budget) : null, rules, expiresAt }; for (const rule of input.carry ?? []) { if (rule.mode !== 'personal_github_publish' && rule.mode !== 'personal_github_merge' && rule.mode !== 'recurring_payment') continue; diff --git a/src/s15/policy/service.ts b/src/s15/policy/service.ts index e5817eba..7ebd3a8a 100644 --- a/src/s15/policy/service.ts +++ b/src/s15/policy/service.ts @@ -85,12 +85,27 @@ export function openPolicyService(options: T.PolicyOptions): T.S15PolicyService if (r.readLimit !== undefined && r.readLimit > readCeiling(p)) v.fail('outside_scope'); if (r.selectionPin && !v.equal(r.selectionPin, r.selection)) v.fail('selection_pinned'); } + /** + * BOT16-01 (ADR 0088): a newer revision that differs from the work's own revision only in which other Bots it names + * (adding a persistent Bot) leaves this work's authority unchanged. Compared from the work's + * Bot point of view: every other Bot reference is dropped from the top-level and rule Bot lists of both revisions, + * and everything else, including this Bot's own memberships, must be identical. An unchanged reinstall is not exempt. + */ + function sameForBot(w: T.WorkAuthority, p: T.StandingPolicy): boolean { + if (w.policyRevision >= p.revision || w.ownerGeneration !== p.ownerGeneration) return false; + const old = parse(ledger.get('SELECT json FROM policies WHERE revision=?', w.policyRevision)); + if (!old || w.policyHash !== v.digest('s15.policy.v1', old)) return false; + const view = (x: T.StandingPolicy) => { const own = (refs: T.Ref[]) => refs.filter(r => r.id === w.request.bot.id); + return v.canonical({ ...x, revision: 0, bots: own(x.bots), rules: x.rules.map(r => ({ ...r, bots: own(r.bots) })) }); }; + // An owner's reinstall of the same rules still starts fresh: only a change to other Bots' membership is exempt. + return v.canonical({ ...old, revision: 0 }) !== v.canonical({ ...p, revision: 0 }) && view(old) === view(p); + } function currentWork(workId: string): T.WorkAuthority { const row = ledger.get<{ json: string; revoked: number }>('SELECT json,revoked FROM works WHERE id=?', workId); if (!row || row.revoked) v.fail('authority_revoked'); const w = JSON.parse(row.json) as T.WorkAuthority; const p = policyCurrent(); if (w.ownerGeneration !== options.ownerGeneration()) v.fail('authority_revoked'); - if (w.policyRevision !== p.revision || w.policyHash !== v.digest('s15.policy.v1', p)) v.fail('stale_authority'); + if ((w.policyRevision !== p.revision || w.policyHash !== v.digest('s15.policy.v1', p)) && !sameForBot(w, p)) v.fail('stale_authority'); if (time() >= w.expiresAt) v.fail('expired'); checkRequest(p, w.request); if (!options.catalogs.allows(clone(w), null)) v.fail('outside_scope'); diff --git a/src/s15/runtime-config.ts b/src/s15/runtime-config.ts index 3288f1ae..d4e0c1ab 100644 --- a/src/s15/runtime-config.ts +++ b/src/s15/runtime-config.ts @@ -32,6 +32,8 @@ export interface S15RuntimeConfig { /** Explicit S16 working computers (`s16-computers/v1`, TEAM16-01): per-work leases, private computers and 30-minute * idle release with on-demand start. Omission keeps S15 working-computer behavior. */ computers?: boolean; + /** Explicit S16 persistent Bots and delegation (`s16-bots/v1`, BOT16-01/DELEG16-01). Omission keeps the single main Bot. */ + bots?: boolean; recurring?: import('./policy/recurring').RecurringRuleOptions; /** * The team's working-computer pool (POOL15-01). `id` names the pool and its default member; with `local` the @@ -76,6 +78,7 @@ export function parseS15RuntimeConfig(raw: unknown): S15RuntimeConfig { if (value.routines !== undefined && typeof value.routines !== 'boolean') throw new AppError('invalid_s15_config'); if (value.notifications !== undefined && typeof value.notifications !== 'boolean') throw new AppError('invalid_s15_config'); if (value.computers !== undefined && typeof value.computers !== 'boolean') throw new AppError('invalid_s15_config'); + if (value.bots !== undefined && typeof value.bots !== 'boolean') throw new AppError('invalid_s15_config'); if (value.policy && (!value.disclosure || value.policy.resources.some(r => !value.disclosure!.sources.some(s => equal(s.resource, r))))) throw new AppError('classification_unknown'); return value; } diff --git a/src/s16/bots/delegation.ts b/src/s16/bots/delegation.ts new file mode 100644 index 00000000..980ee2eb --- /dev/null +++ b/src/s16/bots/delegation.ts @@ -0,0 +1,170 @@ +import { AppError } from "../../errors"; +import type { ConversationApplication } from "../../s15/conversation/application"; +import type { ConversationPolicyWork } from "../../s15/conversation/policy-port"; +import type { ConversationRequest, ConversationToolPort, ConversationTools } from "../../s15/conversation/types"; +import { canonical, content, digest, exact, identity } from "../../s15/conversation/validation"; +import type { DisclosureService } from "../../s15/disclosure"; +import type { MemoryService } from "../../s15/memory"; +import type { OwnerActor, Ref, S15PolicyService } from "../../s15/policy"; +import type { TeamService } from "../../s15/team"; +import type { BotDirectory } from "./service"; +import { delegationLimits } from "./types"; + +/** Child states after which a parent stops waiting. A held child keeps its own needs-you item for the owner. */ +const settled = new Set(["completed", "blocked", "stopped", "held", "proposed", "reconcile_only", "accepted"]); +const uid = () => crypto.randomUUID().replaceAll("-", ""); +const resultChars = 8000; + +/** Trusted composition of the Bot tools; every port is protected host code. */ +export interface BotToolOptions { + conversation: ConversationApplication; + directory: BotDirectory; + policy: S15PolicyService; + team: TeamService; + memory?: MemoryService; + disclosure?: DisclosureService; + /** Re-establishes the request's recorded owner; null when the owner generation changed. */ + owner(snapshot: { id: string; generation: number }): OwnerActor | null; + /** Wait between child state reads (default 25 ms). */ + pollMs?: number; +} + +/** + * BOT16-01 / DELEG16-01 host tools (ADR 0088). `delegate` hands bounded assignments to enabled peer Bots or to + * temporary workers and waits for their results; `propose_bot` (main Bot only) drafts an inert Bot proposal card. + * Neither grants authority: children run inside the parent's frozen scope and charged slice, and a proposal does + * nothing until the owner accepts its exact hash. A delegated child is offered neither tool. + */ +export function createBotTools(options: BotToolOptions) { + const { conversation, directory, policy, team } = options; + const parentOf = (workId: string): ConversationRequest => { + const row = conversation.store.db.query<{ id: string }, [string]>("SELECT id FROM requests WHERE json_extract(record_json,'$.authority.id')=? LIMIT 1").get(workId); + if (!row) throw new AppError("delegation_unavailable", 409); + return conversation.store.request(row.id); + }; + const ownerOf = (request: ConversationRequest) => { const actor = options.owner(request.owner); if (!actor) throw new AppError("authentication_required", 401); return actor; }; + const assignmentRef = (sourceId: string, generation: number): Ref => ({ id: sourceId, revision: 1, generation }); + const peers = (owner: OwnerActor, self: string) => directory.view(owner).bots.filter(bot => bot.id !== self && bot.state === "active" && bot.assignments); + + /** Capture the task as a TEAM source carrying its context's privacy ancestry (claims and file pins). */ + function capture(work: ConversationPolicyWork, parent: ConversationRequest, task: string, kind: "peer" | "temporary"): string { + const context = team.bind(work.actor), sourceId = `as_${uid()}`; + const lineage = parent.memory && options.memory ? options.memory.contextLineage(context, parent.receipt.rootRequestId) : { sourceIds: [], private: false, filePins: [] }; + // Text written from Bot-private context stays private to that Bot: only a temporary worker (the same Bot) can take it. + if (lineage.private && kind === "peer") throw new AppError("private_context", 409); + team.capture(context, { id: sourceId, text: task, scope: lineage.private ? { kind: "bot", botId: work.authority.request.bot.id, origin: "s15" } : { kind: "team" }, + origin: { kind: "work", id: work.authority.id, conversationId: parent.receipt.conversationId, at: conversation.clock.now() }, resources: [], parents: lineage.sourceIds, ...(lineage.filePins.length ? { filePins: lineage.filePins } : {}) }); + // DATA15: the task inherits every source this work already read, so each later send of the child is checked for them. + options.disclosure?.derive(work.actor, assignmentRef(sourceId, work.authority.ownerGeneration), "text"); + return sourceId; + } + + function delegateTool(work: ConversationPolicyWork, parent: ConversationRequest, input: { signal: AbortSignal; guard: () => void }): ConversationTools["tools"][number] { + const owner = ownerOf(parent), self = work.authority.request.bot.id; + const available = peers(owner, self).map(bot => `- ${bot.id}: ${bot.name || "main"}${bot.responsibility ? ` — ${bot.responsibility.slice(0, 200)}` : ""}`).join("\n"); + const access = parent.intent.scope?.access ?? "read"; + return { access, definition: { + name: "delegate", + description: `Hand up to ${delegationLimits.perCall} bounded assignments to other Bots and wait for their results. Use "temporary" for a short-lived helper that works as you, or a Bot id below. Each assignee works only inside your current task's scope with a small share of your remaining budget, and without your conversation history or memory, so write each task self-contained. Results return here; you stay responsible for the final answer.\n${available ? `Bots that accept assignments:\n${available}` : "No other Bot accepts assignments right now; use \"temporary\"."}`, + inputSchema: { type: "object", additionalProperties: false, required: ["assignments"], properties: { assignments: { type: "array", minItems: 1, maxItems: delegationLimits.perCall, items: { type: "object", additionalProperties: false, required: ["to", "task"], properties: { to: { type: "string", description: "\"temporary\" or a Bot id" }, task: { type: "string", maxLength: delegationLimits.taskChars } } } } } }, + execution: "exclusive", effect: access === "read" ? "read-only" : "mutating", + async execute(value) { + input.guard(); + exact(value, ["assignments"]); + const list = (value as { assignments: unknown }).assignments; + if (!Array.isArray(list) || !list.length || list.length > delegationLimits.perCall) throw new AppError("invalid_data"); + const results: { to: string; state: string; code: string | null; result: string | null; requestId: string | null }[] = []; + const started: { index: number; requestId: string }[] = []; + for (const item of list) { + exact(item, ["to", "task"]); + const to = (item as { to: unknown }).to, task = (item as { task: unknown }).task; + if (typeof to !== "string" || typeof task !== "string" || [...task].length > delegationLimits.taskChars) throw new AppError("invalid_data"); + content(task, delegationLimits.taskChars * 4); + try { + input.guard(); + const kind = to === "temporary" ? "temporary" as const : "peer" as const; + if (kind === "peer") { identity(to); if (!peers(owner, self).some(bot => bot.id === to)) throw new AppError("bot_unavailable", 409); } + const botId = kind === "peer" ? to : self, sourceId = capture(work, parentOf(work.authority.id), task, kind); + const receipt = conversation.submitDelegation(owner, { parentRequestId: parent.receipt.rootRequestId, kind, botId, fromBotId: self, task, sourceId, bind: conversationId => directory.bindAssignment(conversationId, botId) }, delegationLimits); + started.push({ index: results.length, requestId: receipt.rootRequestId }); + results.push({ to, state: "running", code: null, result: null, requestId: receipt.rootRequestId }); + } catch (error) { results.push({ to, state: "not_started", code: error instanceof AppError ? error.code : "delegation_unavailable", result: null, requestId: null }); } + } + const stopAll = () => { for (const child of started) { try { conversation.stop(owner, child.requestId); } catch { /* Settled. */ } } }; + try { + while (started.some(child => !settled.has(conversation.store.request(child.requestId).state))) { + if (input.signal.aborted) throw new AppError("aborted"); + input.guard(); + await new Promise(resolve => setTimeout(resolve, options.pollMs ?? 25)); + } + } catch (error) { stopAll(); throw error; } + input.guard(); + for (const child of started) { + const current = conversation.store.request(child.requestId), row = results[child.index]!; + row.state = current.state; row.code = current.code; + if (current.state !== "completed" || current.result === null) continue; + try { + // The child's own publication guard; then its read ancestry joins this work before the text reaches the model. + const view = conversation.view(owner, child.requestId); + if (view.result === null) continue; + if (options.disclosure && current.authority) { + const ref = assignmentRef(`ar_${digest(child.requestId).slice(0, 40)}`, work.authority.ownerGeneration); + options.disclosure.derive(policy.control.bindWork(current.authority.id), ref, "text"); + options.disclosure.inherit(work.actor, ref); + } + row.result = [...view.result].slice(0, resultChars).join(""); + } catch (error) { row.state = "withheld"; row.code = error instanceof AppError ? error.code : "result_unavailable"; } + } + input.guard(); + return { content: JSON.stringify({ assignments: results.map(({ requestId: _id, ...row }) => row) }) }; + } } }; + } + + function proposeTool(work: ConversationPolicyWork, parent: ConversationRequest, input: { guard: () => void }): ConversationTools["tools"][number] { + return { access: "read", definition: { + name: "propose_bot", + description: "Draft a new persistent Bot for the owner to review. This creates only a proposal card in this conversation; nothing exists until the owner accepts it there. The new Bot gets the team's default resources and rules.", + inputSchema: { type: "object", additionalProperties: false, required: ["name", "responsibility"], properties: { name: { type: "string", maxLength: 60 }, responsibility: { type: "string", maxLength: 2000 } } }, + execution: "exclusive", effect: "read-only", + async execute(value) { + input.guard(); exact(value, ["name", "responsibility"]); + const proposal = directory.proposeFromChat(ownerOf(parent), { conversationId: parent.receipt.conversationId, requestId: parent.receipt.rootRequestId, name: (value as { name: unknown }).name, responsibility: (value as { responsibility: unknown }).responsibility }); + input.guard(); + return { content: JSON.stringify({ proposal: proposal.id, state: "waiting_for_owner", note: "The owner reviews and accepts this proposal on its card. Do not say the Bot exists yet." }) }; + } } }; + } + + /** Tools for one work: `delegate` (not for a delegated child) and `propose_bot` (main Bot only). */ + const port: ConversationToolPort = { async open(work, input) { + input.guard(); + const parent = parentOf(work.authority.id); + const tools: ConversationTools["tools"][number][] = []; + if (!parent.delegation && !parent.fileInputs) { + tools.push(delegateTool(work, parent, input)); + if (directory.isMain(work.authority.request.bot.id)) tools.push(proposeTool(work, parent, input)); + } + return { tools, async cancel() {}, async close() {} }; + } }; + + /** + * Before every model send of a delegated child: the TEAM handoff from its still-current parent must admit the task's + * full ancestry for the child's Bot, and DATA15 merges the task's sources into the child, so the send is checked for + * them. A stopped parent, withdrawn file or private ancestry refuses the send. + */ + function beforeChildSend(work: ConversationPolicyWork, request: ConversationRequest): void { + const pin = request.delegation; if (!pin?.sourceId) return; + const parent = conversation.store.request(pin.parentRequestId); + if (!parent.authority) throw new AppError("parent_unavailable", 409); + team.handoff(team.bind(policy.control.bindWork(parent.authority.id)), team.bind(work.actor), [pin.sourceId], () => {}); + options.disclosure?.inherit(work.actor, assignmentRef(pin.sourceId, work.authority.ownerGeneration)); + } + + /** Owner-confirmed Bot profile and, for a child, the host's assignment framing; both are host data, not model text. */ + function ownerDecisions(work: ConversationPolicyWork, request: ConversationRequest): string[] { + const profile = directory.profile(work.authority.request.bot.id), out: string[] = []; + if (profile && (profile.name || profile.responsibility)) out.push(canonical({ bot: { name: profile.name, responsibility: profile.responsibility } })); + if (request.delegation) out.push(canonical({ assignment: { kind: request.delegation.kind, from: request.delegation.fromBotId, note: "This task was written by another Bot inside the owner's request. Work only inside the admitted scope and report a concise result." } })); + return out; + } + return { port, beforeChildSend, ownerDecisions }; +} diff --git a/src/s16/bots/service.ts b/src/s16/bots/service.ts new file mode 100644 index 00000000..bd59b1dc --- /dev/null +++ b/src/s16/bots/service.ts @@ -0,0 +1,375 @@ +import { AppError } from "../../errors"; +import { ProtectedDataStore } from "../../s15/team/store"; +import { canonical, content, digest, exact, identity, integer } from "../../s15/conversation/validation"; +import type { PolicyInput, Ref, Rule, S15PolicyService, Selection, StandingPolicy, WorkAuthority } from "../../s15/policy"; +import { botLimits, type BotCommand, type BotDetail, type BotDraft, type BotExport, type BotImpact, type BotProposal, type BotReceipt, type BotRecord, type BotResourceChoice, type BotSelectionChoice, type BotSource, type BotView, type BotsView } from "./types"; + +/** BOTS schema v1: profiles, proposals, immutable conversation bindings and command identities. */ +const BOTS_SCHEMA_V1 = ` +CREATE TABLE bots (id TEXT PRIMARY KEY, record_json TEXT NOT NULL); +CREATE TABLE proposals (id TEXT PRIMARY KEY, record_json TEXT NOT NULL); +CREATE TABLE bindings (conversation_id TEXT PRIMARY KEY, bot_id TEXT NOT NULL); +CREATE TABLE commands (id TEXT PRIMARY KEY, payload_hash TEXT NOT NULL, result_json TEXT NOT NULL); +CREATE TRIGGER immutable_binding_update BEFORE UPDATE ON bindings BEGIN SELECT RAISE(ABORT,'binding_immutable'); END; +CREATE TRIGGER immutable_binding_delete BEFORE DELETE ON bindings BEGIN SELECT RAISE(ABORT,'binding_immutable'); END; +`; +const same = (a: unknown, b: unknown) => canonical(a) === canonical(b); +/** Rules that carry the team defaults to another Bot. Personal GitHub rules and recurring payments were approved for named Bots. */ +const teamRule = (rule: Rule) => !["personal_github_publish", "personal_github_merge", "recurring_payment"].includes(rule.mode); +const uid = () => crypto.randomUUID().replaceAll("-", ""); + +/** Protected composition. Every port is trusted host code; none is reachable from a model. */ +export interface BotDirectoryOptions { + path: string; installationId: string; create: boolean; now: () => number; + /** The installation's main Bot and default route; the main Bot is never archived. */ + main: { bot: Ref; selection: Selection }; + policy: S15PolicyService; + /** The configured catalog for Bots this directory does not own. */ + configured(id: string): { ref: Ref; active: boolean } | null; + /** Approved routes the owner can choose, already intersected with the current standing policy. */ + selections(owner: Owner): BotSelectionChoice[]; + /** Team resources a new Bot uses by default, with owner-facing labels. */ + resources(owner: Owner): BotResourceChoice[]; + /** Export/import names of approved routes; never credentials or accounts. */ + routes(): { selection: Selection; provider: string; modelId: string }[]; + conversations: { + /** True once the conversation has any request; a started conversation keeps its Bot. */ + started(conversationId: string): boolean; + /** Unfinished requests in the given conversations, plus delegated children assigned to the Bot. */ + unfinished(owner: Owner, botId: string, conversationIds: readonly string[]): { requestId: string; conversationId: string }[]; + /** Recent conversations (newest first) among the given IDs, for the Bot page. */ + recent(owner: Owner, conversationIds: readonly string[]): { conversationId: string; firstMessage: string; latestAt: number; assignment: boolean }[]; + stop(owner: Owner, requestId: string): void; + }; + routines?: { active(owner: Owner, conversationIds: readonly string[]): { id: string; name: string; revision: number }[]; pause(owner: Owner, routineId: string, revision: number): void }; +} + +/** + * BOT16-01 protected Bot directory (`s16-bots/v1`, ADR 0088). It owns created Bots' catalog identities, the owner's + * profile text, exact proposals and the immutable conversation-to-Bot binding. A Bot joins the standing policy only + * through the owner's acceptance of one exact proposal, which installs a revision naming it in every rule that names + * the main Bot (the team defaults). Archive makes its catalog identity inactive, so POLICY refuses all of its work at + * the next guard, and stops (revokes) its unfinished work; restore reactivates the same identity, so history and + * sourced memory return while nothing revoked can resume. + */ +export class BotDirectory { + readonly store: ProtectedDataStore; + constructor(readonly options: BotDirectoryOptions) { + this.store = new ProtectedDataStore(options.path, options.installationId, options.create, 0x4a494236, BOTS_SCHEMA_V1); + } + close(): void { this.store.close(); } + + #records(): BotRecord[] { return this.store.db.query<{ record_json: string }, []>("SELECT record_json FROM bots ORDER BY rowid").all().map(row => JSON.parse(row.record_json) as BotRecord); } + #record(id: string): BotRecord | null { const row = this.store.db.query<{ record_json: string }, [string]>("SELECT record_json FROM bots WHERE id=?").get(id); return row ? JSON.parse(row.record_json) as BotRecord : null; } + #save(record: BotRecord): void { this.store.db.query("INSERT INTO bots VALUES (?,?) ON CONFLICT(id) DO UPDATE SET record_json=excluded.record_json").run(record.id, canonical(record)); } + #proposal(id: string): BotProposal | null { const row = this.store.db.query<{ record_json: string }, [string]>("SELECT record_json FROM proposals WHERE id=?").get(id); return row ? JSON.parse(row.record_json) as BotProposal : null; } + #saveProposal(value: BotProposal): void { this.store.db.query("INSERT INTO proposals VALUES (?,?) ON CONFLICT(id) DO UPDATE SET record_json=excluded.record_json").run(value.id, canonical(value)); } + #policy(owner: Owner): StandingPolicy | null { return this.options.policy.owner.current(owner as never); } + #owner(owner: Owner): void { this.options.policy.owner.current(owner as never); } + + /** POLICY catalog for Bots this directory owns (created, or a configured Bot it archived/restored). Others fall through. */ + catalog(id: string): { ref: Ref; active: boolean } | null { + const record = this.#record(id); + return record?.ref ? { ref: structuredClone(record.ref), active: record.state === "active" } : null; + } + /** Catalog identities of created Bots in creation order, for the default standing-policy proposal. */ + createdRefs(): Ref[] { return this.#records().filter(record => record.origin === "created" && record.ref).map(record => structuredClone(record.ref!)); } + isMain(botId: string): boolean { return botId === this.options.main.bot.id; } + /** Current profile of a Bot: stored record, or the default of the main/configured Bot. */ + profile(botId: string): BotRecord | null { + identity(botId); + const stored = this.#record(botId); if (stored) return stored; + if (this.isMain(botId)) return this.#default(this.options.main.bot, "main"); + const configured = this.options.configured(botId); + return configured ? this.#default(configured.ref, "configured") : null; + } + #default(ref: Ref, origin: "main" | "configured"): BotRecord { + return { contract: "s16-bot/v1", id: ref.id, revision: 0, origin, ref: null, name: origin === "main" ? "" : ref.id, responsibility: "", selection: null, excluded: [], assignments: true, state: "active", source: { kind: "owner" }, createdAt: 0, updatedAt: 0, archivedAt: null }; + } + /** The Bot a conversation talks to: its immutable binding, or the main Bot. */ + conversationBot(conversationId: string): string { + identity(conversationId); + return this.store.db.query<{ bot_id: string }, [string]>("SELECT bot_id FROM bindings WHERE conversation_id=?").get(conversationId)?.bot_id ?? this.options.main.bot.id; + } + /** Conversations bound to a Bot (the main Bot's unbound conversations are not listed here). */ + boundConversations(botId: string): string[] { return this.store.db.query<{ conversation_id: string }, [string]>("SELECT conversation_id FROM bindings WHERE bot_id=? ORDER BY rowid DESC LIMIT 512").all(botId).map(row => row.conversation_id); } + /** + * Admission resolution for a conversation's Bot: its current catalog reference, the route new work uses and its + * excluded resources. An archived Bot refuses (`bot_archived`) before anything is accepted. + */ + resolve(conversationId: string, policySelections?: readonly Selection[]): { bot: Ref; selection: Selection; excluded: string[]; record: BotRecord } { + const botId = this.conversationBot(conversationId), record = this.profile(botId); + if (!record) throw new AppError("bot_unavailable", 409); + if (record.state !== "active") throw new AppError("bot_archived", 409); + const ref = this.isMain(botId) ? this.options.main.bot : record.ref ?? this.options.configured(botId)?.ref; + if (!ref) throw new AppError("bot_unavailable", 409); + const selection = record.selection && (!policySelections || policySelections.some(s => same(s, record.selection))) ? record.selection : this.options.main.selection; + return { bot: structuredClone(ref), selection: structuredClone(selection), excluded: [...record.excluded], record }; + } + /** Ingress fence: a conversation of an archived Bot accepts no new message, routine run or assignment. */ + admitConversation(conversationId: string): void { this.resolve(conversationId); } + /** Catalog `allows` intersection: a Bot never uses a team resource the owner excluded for it. */ + allows(work: WorkAuthority, resources: readonly Ref[]): boolean { + const record = this.#record(work.request.bot.id); + return !record || !resources.some(resource => record.excluded.includes(resource.id)); + } + + #bots(owner: Owner): BotRecord[] { + const policy = this.#policy(owner), stored = new Map(this.#records().map(record => [record.id, record] as const)); + const ids = [this.options.main.bot.id, ...(policy?.bots ?? []).map(bot => bot.id), ...stored.keys()].filter((id, index, all) => all.indexOf(id) === index); + return ids.flatMap(id => { const record = this.profile(id); return record ? [record] : []; }).slice(0, botLimits.bots * 2); + } + #view(record: BotRecord, selections: readonly BotSelectionChoice[]): BotView { + const chosen = record.selection ? selections.find(choice => same(choice.selection, record.selection)) ?? null : null; + return { id: record.id, revision: record.revision, origin: record.origin, main: this.isMain(record.id), name: record.name, responsibility: record.responsibility, + selection: record.selection ? structuredClone(record.selection) : null, selectionLabel: chosen?.label ?? null, selectionUnavailable: !!record.selection && !chosen, + excluded: [...record.excluded], assignments: record.assignments, state: record.state, createdAt: record.createdAt, archivedAt: record.archivedAt }; + } + /** Owner Bots page: profiles, pending proposals and current choices. Reading changes nothing. */ + view(owner: Owner): BotsView { + this.#owner(owner); + const selections = this.options.selections(owner), policy = this.#policy(owner); + const proposals = this.store.db.query<{ record_json: string }, []>("SELECT record_json FROM proposals ORDER BY rowid DESC LIMIT 64").all().map(row => JSON.parse(row.record_json) as BotProposal).filter(p => p.state === "pending").reverse(); + return { contract: "s16-client-bots/v1", bots: this.#bots(owner).map(record => this.#view(record, selections)), proposals, selections, resources: this.options.resources(owner), + creation: !policy ? { available: false, code: "policy_required" } : this.#records().filter(r => r.origin === "created").length >= botLimits.bots ? { available: false, code: "bot_capacity" } : { available: true } }; + } + /** One Bot with its recent conversations and what archiving would stop and pause. */ + detail(owner: Owner, botId: string): BotDetail { + this.#owner(owner); const record = this.profile(botId); + if (!record || !this.#bots(owner).some(r => r.id === botId)) throw new AppError("not_found", 404); + return { contract: "s16-client-bot/v1", bot: this.#view(record, this.options.selections(owner)), conversations: this.options.conversations.recent(owner, this.boundConversations(botId)).slice(0, 32), impact: this.impact(owner, botId) }; + } + impact(owner: Owner, botId: string): BotImpact { + const conversations = this.boundConversations(botId); + return { running: this.options.conversations.unfinished(owner, botId, conversations), routines: (this.options.routines?.active(owner, conversations) ?? []).map(({ id, name }) => ({ id, name })) }; + } + + #draft(owner: Owner, value: unknown): BotDraft { + canonical(value); exact(value, ["contract", "name", "responsibility", "selection", "excluded", "assignments"]); + if (value.contract !== "s16-bot-draft/v1" || typeof value.assignments !== "boolean") throw new AppError("invalid_bot"); + const name = this.#name(value.name), responsibility = this.#responsibility(value.responsibility); + if (!Array.isArray(value.excluded) || value.excluded.length > botLimits.excluded || new Set(value.excluded).size !== value.excluded.length) throw new AppError("invalid_bot"); + value.excluded.forEach(identity); + const resources = this.options.resources(owner); + if ((value.excluded as string[]).some(id => !resources.some(r => r.id === id))) throw new AppError("outside_scope", 409); + if (value.selection !== null && !this.options.selections(owner).some(choice => same(choice.selection, value.selection))) throw new AppError("selection_unavailable", 409); + return { contract: "s16-bot-draft/v1", name, responsibility, selection: value.selection === null ? null : structuredClone(value.selection as Selection), excluded: [...(value.excluded as string[])].sort(), assignments: value.assignments }; + } + #name(value: unknown): string { + if (typeof value !== "string" || !value.trim() || [...value].length > botLimits.nameChars || /[\n\r\t]/.test(value)) throw new AppError("invalid_bot_name"); + content(value, botLimits.nameChars * 4); return value.trim(); + } + #responsibility(value: unknown): string { + if (typeof value !== "string" || [...value].length > botLimits.responsibilityChars) throw new AppError("invalid_bot_responsibility"); + if (value.trim()) content(value, botLimits.responsibilityChars * 4); return value.trim(); + } + #propose(owner: Owner | null, draft: BotDraft, source: BotSource, missing: string[], resources: BotResourceChoice[], policyRevision: number): BotProposal { + const value = { draft, source, policyRevision, resources, missing }; + const proposalHash = digest(`s16.bot.proposal.v1\n${canonical(value)}`); + return this.store.transaction(() => { + if (owner) this.#owner(owner); + const prior = this.store.db.query<{ record_json: string }, [string]>("SELECT record_json FROM proposals WHERE json_extract(record_json,'$.proposalHash')=? AND json_extract(record_json,'$.state')='pending'").get(proposalHash); + if (prior) return JSON.parse(prior.record_json) as BotProposal; + const pending = this.store.db.query<{ n: number }, []>("SELECT count(*) AS n FROM proposals WHERE json_extract(record_json,'$.state')='pending'").get()!.n; + if (pending >= botLimits.proposals) throw new AppError("bot_proposal_capacity", 409); + this.store.capacity(); + const proposal: BotProposal = { contract: "s16-bot-proposal/v1", id: `bp_${uid()}`, ...structuredClone(value), proposalHash, state: "pending", createdAt: this.options.now(), botId: null }; + this.#saveProposal(proposal); return proposal; + }); + } + /** Owner form or edited card: a new exact proposal. Nothing is created until the owner accepts this hash. */ + propose(owner: Owner, raw: unknown, source: BotSource = { kind: "owner" }): BotProposal { + const policy = this.#policy(owner); if (!policy) throw new AppError("policy_required", 409); + return this.#propose(owner, this.#draft(owner, raw), source, [], this.options.resources(owner), policy.revision); + } + /** + * The main Bot's chat proposal (`propose_bot` tool). It is inert data shown as a card in the conversation; model + * text only fills the name and responsibility, and the owner still reviews and accepts the exact proposal. + */ + proposeFromChat(owner: Owner, input: { conversationId: string; requestId: string; name: unknown; responsibility: unknown }): BotProposal { + identity(input.conversationId); identity(input.requestId); + const policy = this.#policy(owner); if (!policy) throw new AppError("policy_required", 409); + const draft: BotDraft = { contract: "s16-bot-draft/v1", name: this.#name(input.name), responsibility: this.#responsibility(input.responsibility), selection: null, excluded: [], assignments: true }; + return this.#propose(owner, draft, { kind: "chat", conversationId: input.conversationId, requestId: input.requestId }, [], this.options.resources(owner), policy.revision); + } + /** Duplicate copies configuration only: never history, memory, files, computer or account data. */ + duplicate(owner: Owner, botId: string, name: unknown): BotProposal { + const record = this.profile(botId); if (!record) throw new AppError("not_found", 404); + const policy = this.#policy(owner); if (!policy) throw new AppError("policy_required", 409); + const resources = this.options.resources(owner), selections = this.options.selections(owner); + const missing = [...record.excluded.filter(id => !resources.some(r => r.id === id)), ...(record.selection && !selections.some(s => same(s.selection, record.selection)) ? ["model"] : [])]; + const draft: BotDraft = { contract: "s16-bot-draft/v1", name: this.#name(name), responsibility: record.responsibility, selection: record.selection && selections.some(s => same(s.selection, record.selection)) ? structuredClone(record.selection) : null, + excluded: record.excluded.filter(id => resources.some(r => r.id === id)), assignments: record.assignments }; + return this.#propose(owner, draft, { kind: "duplicate", botId }, missing, resources, policy.revision); + } + /** Credential-free configuration export. */ + exportBot(owner: Owner, botId: string): BotExport { + this.#owner(owner); const record = this.profile(botId); if (!record) throw new AppError("not_found", 404); + const route = record.selection ? this.options.routes().find(r => same(r.selection, record.selection)) : undefined, labels = new Map(this.options.resources(owner).map(r => [r.id, r.label] as const)); + return { contract: "s16-bot-export/v1", name: record.name, responsibility: record.responsibility, model: route ? { provider: route.provider, modelId: route.modelId, effort: route.selection.effort } : null, + excluded: record.excluded.map(id => ({ id, label: labels.get(id) ?? id })), assignments: record.assignments, exportedAt: this.options.now() }; + } + /** Import validates a bounded export and turns it into a new proposal; unknown references are listed, never guessed. */ + importBot(owner: Owner, raw: unknown): BotProposal { + if (Buffer.byteLength(canonical(raw)) > botLimits.exportBytes) throw new AppError("body_too_large", 413); + exact(raw, ["contract", "name", "responsibility", "model", "excluded", "assignments", "exportedAt"]); + if (raw.contract !== "s16-bot-export/v1" || typeof raw.assignments !== "boolean" || !Array.isArray(raw.excluded) || raw.excluded.length > botLimits.excluded) throw new AppError("invalid_bot_export"); + integer(raw.exportedAt, 0, Number.MAX_SAFE_INTEGER); + const policy = this.#policy(owner); if (!policy) throw new AppError("policy_required", 409); + const resources = this.options.resources(owner), selections = this.options.selections(owner), missing: string[] = [], excluded: string[] = []; + for (const item of raw.excluded) { + exact(item, ["id", "label"]); identity(item.id); content(item.label, 1024); + if (resources.some(r => r.id === item.id)) excluded.push(item.id as string); else missing.push(item.label as string); + } + let selection: Selection | null = null; + if (raw.model !== null) { + exact(raw.model, ["provider", "modelId", "effort"]); content(raw.model.provider, 256); content(raw.model.modelId, 256); identity(raw.model.effort); + const model = raw.model as { provider: string; modelId: string; effort: string }; + const route = this.options.routes().find(r => r.provider === model.provider && r.modelId === model.modelId && r.selection.effort === model.effort && selections.some(s => same(s.selection, r.selection))); + if (route) selection = structuredClone(route.selection); else missing.push(`${model.provider} · ${model.modelId}`); + } + const draft: BotDraft = { contract: "s16-bot-draft/v1", name: this.#name(raw.name), responsibility: this.#responsibility(raw.responsibility), selection, excluded: [...new Set(excluded)].sort(), assignments: raw.assignments }; + return this.#propose(owner, draft, { kind: "import" }, missing, resources, policy.revision); + } + + /** Exact owner commands. Each runs once per request ID; a changed payload under the same ID refuses. */ + command(owner: Owner, raw: unknown): BotReceipt { + canonical(raw); + if (!raw || typeof raw !== "object" || (raw as { contract?: unknown }).contract !== "s16-bot-command/v1") throw new AppError("invalid_bot_command"); + const kind = (raw as { kind?: unknown }).kind, shapes: Record = { "bot.accept": ["proposalId", "proposalHash"], "bot.dismiss": ["proposalId"], "bot.update": ["botId", "expectedRevision", "name", "responsibility", "selection", "assignments"], "bot.archive": ["botId", "expectedRevision"], "bot.restore": ["botId", "expectedRevision"], "conversation.bind": ["conversationId", "botId"] }; + if (typeof kind !== "string" || !shapes[kind]) throw new AppError("invalid_bot_command"); + exact(raw, ["contract", "requestId", "kind", ...shapes[kind]!]); + const command = raw as BotCommand; identity(command.requestId); + const hash = digest(`s16.bot.command.v1\n${canonical(command)}`); + const prior = this.store.db.query<{ payload_hash: string; result_json: string }, [string]>("SELECT payload_hash,result_json FROM commands WHERE id=?").get(command.requestId); + if (prior) { this.#owner(owner); if (prior.payload_hash !== hash) throw new AppError("request_id_conflict", 409); return JSON.parse(prior.result_json) as BotReceipt; } + const receipt = this.#execute(owner, command); + this.store.transaction(() => { if (!this.store.db.query("SELECT 1 FROM commands WHERE id=?").get(command.requestId)) this.store.db.query("INSERT INTO commands VALUES (?,?,?)").run(command.requestId, hash, canonical(receipt)); }); + return receipt; + } + #receipt(command: BotCommand, bot: BotRecord | null, proposal: BotProposal | null, owner: Owner, stopped = 0, paused = 0): BotReceipt { + return { contract: "s16-bot-receipt/v1", requestId: command.requestId, kind: command.kind, bot: bot ? this.#view(bot, this.options.selections(owner)) : null, proposal, stopped, paused }; + } + #execute(owner: Owner, command: BotCommand): BotReceipt { + this.#owner(owner); + switch (command.kind) { + case "bot.accept": return this.#accept(owner, command); + case "bot.dismiss": { + identity(command.proposalId); + const proposal = this.store.transaction(() => { const value = this.#proposal(command.proposalId); if (!value) throw new AppError("not_found", 404); if (value.state !== "pending") throw new AppError("stale_proposal", 409); value.state = "dismissed"; this.#saveProposal(value); return value; }); + return this.#receipt(command, null, proposal, owner); + } + case "bot.update": { + identity(command.botId); integer(command.expectedRevision, 0); + if (typeof command.assignments !== "boolean") throw new AppError("invalid_bot"); + const name = this.#name(command.name), responsibility = this.#responsibility(command.responsibility); + if (command.selection !== null && !this.options.selections(owner).some(choice => same(choice.selection, command.selection))) throw new AppError("selection_unavailable", 409); + const record = this.store.transaction(() => { + const current = this.profile(command.botId); + if (!current || !this.#bots(owner).some(r => r.id === command.botId)) throw new AppError("not_found", 404); + if (current.revision !== command.expectedRevision) throw new AppError("stale_revision", 409); + if (current.state !== "active") throw new AppError("bot_archived", 409); + const next: BotRecord = { ...current, revision: current.revision + 1, name, responsibility, selection: command.selection ? structuredClone(command.selection) : null, assignments: command.assignments, updatedAt: this.options.now(), ...(current.revision === 0 ? { createdAt: this.options.now() } : {}) }; + this.#save(next); return next; + }); + return this.#receipt(command, record, null, owner); + } + case "bot.archive": return this.#archive(owner, command); + case "bot.restore": return this.#restore(owner, command); + case "conversation.bind": { + identity(command.conversationId); identity(command.botId); + const record = this.profile(command.botId); + if (!record || !this.#bots(owner).some(r => r.id === command.botId)) throw new AppError("not_found", 404); + if (record.state !== "active") throw new AppError("bot_archived", 409); + this.store.transaction(() => { + const bound = this.store.db.query<{ bot_id: string }, [string]>("SELECT bot_id FROM bindings WHERE conversation_id=?").get(command.conversationId); + if (bound) { if (bound.bot_id !== command.botId) throw new AppError("conversation_bot_fixed", 409); return; } + // A conversation keeps the Bot it started with: an unbound started conversation belongs to the main Bot. + if (this.options.conversations.started(command.conversationId)) { if (!this.isMain(command.botId)) throw new AppError("conversation_bot_fixed", 409); return; } + if (this.isMain(command.botId)) return; + this.store.capacity(); this.store.db.query("INSERT INTO bindings VALUES (?,?)").run(command.conversationId, command.botId); + }); + return this.#receipt(command, record, null, owner); + } + } + } + /** Bind a delegated child conversation to the Bot that runs it (protected host path, not an owner command). */ + bindAssignment(conversationId: string, botId: string): void { + identity(conversationId); identity(botId); + this.store.transaction(() => { + const bound = this.store.db.query<{ bot_id: string }, [string]>("SELECT bot_id FROM bindings WHERE conversation_id=?").get(conversationId); + if (bound) { if (bound.bot_id !== botId) throw new AppError("conversation_bot_fixed", 409); return; } + if (this.isMain(botId)) return; + this.store.capacity(); this.store.db.query("INSERT INTO bindings VALUES (?,?)").run(conversationId, botId); + }); + } + /** Install the owner's next standing-policy revision with `transform`; nothing else in the policy changes. */ + #install(owner: Owner, requestId: string, transform: (policy: PolicyInput) => PolicyInput): void { + const current = this.#policy(owner); if (!current) throw new AppError("policy_required", 409); + const { contract: _contract, revision, ownerGeneration: _generation, ...input } = structuredClone(current); + this.options.policy.owner.install(owner as never, `bot_${digest(requestId).slice(0, 40)}`, revision, transform(input)); + } + #accept(owner: Owner, command: Extract): BotReceipt { + identity(command.proposalId); if (typeof command.proposalHash !== "string" || !/^[a-f0-9]{64}$/.test(command.proposalHash)) throw new AppError("invalid_bot_command"); + const proposal = this.#proposal(command.proposalId); + if (!proposal) throw new AppError("not_found", 404); + if (proposal.state !== "pending" || proposal.proposalHash !== command.proposalHash) throw new AppError("stale_proposal", 409); + const policy = this.#policy(owner); if (!policy) throw new AppError("policy_required", 409); + const id = `bot_${digest(command.requestId).slice(0, 24)}`, ref: Ref = { id, revision: 1, generation: 1 }; + if (this.#record(id) || this.options.configured(id) || this.isMain(id)) throw new AppError("request_id_conflict", 409); + // This exact command already installed the Bot when the process stopped before recording it. + const installed = policy.bots.some(b => same(b, ref)); + // The owner reviewed this revision's defaults; a changed policy or resource list needs a fresh proposal. + if (!installed && (policy.revision !== proposal.policyRevision || !same(this.options.resources(owner), proposal.resources))) throw new AppError("stale_proposal", 409); + if (proposal.draft.selection && !this.options.selections(owner).some(choice => same(choice.selection, proposal.draft.selection))) throw new AppError("stale_proposal", 409); + if (this.#records().filter(r => r.origin === "created").length >= botLimits.bots) throw new AppError("bot_capacity", 409); + const main = this.options.main.bot; + // Team defaults: the new Bot joins every rule that names the main Bot, so it works under exactly the same rules. + // Separately approved personal rules and recurring payments stay with the Bots the owner approved them for. A retry + // after a crash between this install and the record below finds the Bot already installed and only records it. + if (!installed) this.#install(owner, command.requestId, input => ({ ...input, bots: [...input.bots, ref], rules: input.rules.map(rule => teamRule(rule) && rule.bots.some(b => b.id === main.id) ? { ...rule, bots: [...rule.bots, ref] } : rule) })); + const now = this.options.now(); + const record: BotRecord = { contract: "s16-bot/v1", id, revision: 1, origin: "created", ref, name: proposal.draft.name, responsibility: proposal.draft.responsibility, selection: proposal.draft.selection, excluded: [...proposal.draft.excluded], assignments: proposal.draft.assignments, + state: "active", source: structuredClone(proposal.source), createdAt: now, updatedAt: now, archivedAt: null }; + const accepted = this.store.transaction(() => { + const current = this.#proposal(proposal.id)!; if (current.state !== "pending") throw new AppError("stale_proposal", 409); + this.store.capacity(); this.#save(record); current.state = "accepted"; current.botId = id; this.#saveProposal(current); return current; + }); + return this.#receipt(command, record, accepted, owner); + } + #archive(owner: Owner, command: Extract): BotReceipt { + identity(command.botId); integer(command.expectedRevision, 0); + if (this.isMain(command.botId)) throw new AppError("bot_main_required", 409); + const record = this.store.transaction(() => { + const current = this.profile(command.botId); + if (!current || !this.#bots(owner).some(r => r.id === command.botId)) throw new AppError("not_found", 404); + if (current.revision !== command.expectedRevision) throw new AppError("stale_revision", 409); + if (current.state !== "active") throw new AppError("bot_archived", 409); + const ref = current.ref ?? this.options.configured(current.id)?.ref ?? null; + if (!ref) throw new AppError("bot_unavailable", 409); + // The catalog identity turns inactive here, before any stop below: POLICY refuses this Bot's work at its next guard. + const next: BotRecord = { ...current, revision: current.revision + 1, ref: structuredClone(ref), state: "archived", updatedAt: this.options.now(), archivedAt: this.options.now() }; + this.#save(next); return next; + }); + let stopped = 0, paused = 0; + const conversations = this.boundConversations(record.id); + for (const item of this.options.conversations.unfinished(owner, record.id, conversations)) { try { this.options.conversations.stop(owner, item.requestId); stopped++; } catch { /* Already settled or fenced by POLICY. */ } } + for (const routine of this.options.routines?.active(owner, conversations) ?? []) { try { this.options.routines!.pause(owner, routine.id, routine.revision); paused++; } catch { /* Its ingress refuses an archived Bot anyway. */ } } + return this.#receipt(command, record, null, owner, stopped, paused); + } + #restore(owner: Owner, command: Extract): BotReceipt { + identity(command.botId); integer(command.expectedRevision, 0); + // The same identity returns, so the Bot's history and sourced memory are readable again. Nothing it had unfinished + // resumes: archive revoked those works, and a settled or reconcile-only request never runs again by itself. + const record = this.store.transaction(() => { + const current = this.#record(command.botId); + if (!current) throw new AppError("not_found", 404); + if (current.revision !== command.expectedRevision) throw new AppError("stale_revision", 409); + if (current.state !== "archived" || !current.ref) throw new AppError("bot_not_archived", 409); + const next: BotRecord = { ...current, revision: current.revision + 1, state: "active", updatedAt: this.options.now(), archivedAt: null }; + this.#save(next); return next; + }); + return this.#receipt(command, record, null, owner); + } + +} diff --git a/src/s16/bots/types.ts b/src/s16/bots/types.ts new file mode 100644 index 00000000..be75efa4 --- /dev/null +++ b/src/s16/bots/types.ts @@ -0,0 +1,123 @@ +import type { Ref, Selection } from "../../s15/policy"; + +/** + * BOT16-01 (`s16-bots/v1`, ADR 0088) persistent Bot contracts. Every field is data: a profile, proposal or export + * never grants execution, account, recipient or approval authority. Authority stays with POLICY (the standing + * policy's Bot list and rules), TEAM (resource scope) and the owner's own commands. + */ + +/** Owner-visible bounds. A Bot's name and responsibility are owner-confirmed text. */ +export const botLimits = Object.freeze({ nameChars: 60, responsibilityChars: 2000, bots: 32, proposals: 64, exportBytes: 16384, excluded: 32 }); + +/** Where a profile came from. `main` is the installation's main Bot; `configured` is a Bot named by the installed policy. */ +export type BotOrigin = "main" | "configured" | "created"; +/** Provenance of a created Bot or a proposal; it is never authority. */ +export type BotSource = + | { kind: "owner" } + | { kind: "chat"; conversationId: string; requestId: string } + | { kind: "duplicate"; botId: string } + | { kind: "import" }; + +/** The exact configuration the owner reviews. `selection: null` uses the installation's default route. */ +export interface BotDraft { + contract: "s16-bot-draft/v1"; + name: string; responsibility: string; + selection: Selection | null; + /** Team resources this Bot may not use; everything else follows the team defaults. */ + excluded: string[]; + /** Accepts bounded assignments from other Bots (DELEG16-01). */ + assignments: boolean; +} + +/** One stored Bot profile. `ref` is the POLICY catalog identity for Bots this directory owns; restore bumps its generation. */ +export interface BotRecord { + contract: "s16-bot/v1"; + id: string; revision: number; origin: BotOrigin; + ref: Ref | null; + name: string; responsibility: string; selection: Selection | null; excluded: string[]; assignments: boolean; + state: "active" | "archived"; + source: BotSource; + createdAt: number; updatedAt: number; archivedAt: number | null; +} + +/** An approved route choice shown to the owner. */ +export interface BotSelectionChoice { selection: Selection; label: string } +/** A team resource shown on a proposal: the new Bot uses it unless it is excluded. */ +export interface BotResourceChoice { id: string; label: string; kind: string } + +/** + * `s16-bot-proposal/v1`: one exact pending proposal. `proposalHash` binds the draft, the standing-policy revision + * and the listed team defaults; any change makes a new proposal that needs its own acceptance. + */ +export interface BotProposal { + contract: "s16-bot-proposal/v1"; + id: string; draft: BotDraft; source: BotSource; + policyRevision: number; + resources: BotResourceChoice[]; + /** Import only: names or references this installation does not have; they were left out of the draft. */ + missing: string[]; + proposalHash: string; + state: "pending" | "accepted" | "dismissed"; + createdAt: number; + /** The created Bot once accepted. */ + botId: string | null; +} + +/** Owner projection of one Bot. Labels come from the owner's own text; `main` is the installation's main Bot. */ +export interface BotView { + id: string; revision: number; origin: BotOrigin; main: boolean; + name: string; responsibility: string; + selection: Selection | null; selectionLabel: string | null; + /** True when the chosen route is no longer approved and new work uses the installation default. */ + selectionUnavailable: boolean; + excluded: string[]; assignments: boolean; + state: "active" | "archived"; + createdAt: number; archivedAt: number | null; +} + +/** `s16-client-bots/v1` bounded owner page. Reading it starts, stops or changes nothing. */ +export interface BotsView { + contract: "s16-client-bots/v1"; + bots: BotView[]; proposals: BotProposal[]; + selections: BotSelectionChoice[]; resources: BotResourceChoice[]; + /** Null when Bots can be created; otherwise why not (for example `policy_required`). */ + creation: { available: true } | { available: false; code: string }; +} +/** Read-only impact of archiving: what stops now and which routines pause. */ +export interface BotImpact { running: { requestId: string; conversationId: string }[]; routines: { id: string; name: string }[] } +/** One Bot with its recent conversations (newest first) and the archive impact. */ +export interface BotDetail { + contract: "s16-client-bot/v1"; + bot: BotView; + conversations: { conversationId: string; firstMessage: string; latestAt: number; assignment: boolean }[]; + impact: BotImpact; +} + +/** `s16-bot-export/v1`: credential-free configuration only; no history, memory, files, computer or account data. */ +export interface BotExport { + contract: "s16-bot-export/v1"; + name: string; responsibility: string; + model: { provider: string; modelId: string; effort: string } | null; + excluded: { id: string; label: string }[]; + assignments: boolean; + exportedAt: number; +} + +/** Exact owner commands. Each names its revision or proposal hash; a repeated request ID returns its first result. */ +export type BotCommand = + | { contract: "s16-bot-command/v1"; requestId: string; kind: "bot.accept"; proposalId: string; proposalHash: string } + | { contract: "s16-bot-command/v1"; requestId: string; kind: "bot.dismiss"; proposalId: string } + | { contract: "s16-bot-command/v1"; requestId: string; kind: "bot.update"; botId: string; expectedRevision: number; name: string; responsibility: string; selection: Selection | null; assignments: boolean } + | { contract: "s16-bot-command/v1"; requestId: string; kind: "bot.archive" | "bot.restore"; botId: string; expectedRevision: number } + | { contract: "s16-bot-command/v1"; requestId: string; kind: "conversation.bind"; conversationId: string; botId: string }; +/** Command result: the affected Bot and proposal, and for an archive what was stopped and paused. */ +export interface BotReceipt { + contract: "s16-bot-receipt/v1"; requestId: string; kind: BotCommand["kind"]; + bot: BotView | null; proposal: BotProposal | null; + stopped: number; paused: number; +} + +/** Bot-to-Bot assignment kinds (DELEG16-01). A temporary worker runs as the assigning Bot and is never persisted. */ +export type DelegationKind = "peer" | "temporary"; +/** Finite delegation bounds of one originating request. Slices are charged to the parent before the child starts. */ +export const delegationLimits = Object.freeze({ perRequest: 6, concurrent: 3, perCall: 3, taskChars: 4000, installationConcurrent: 3 }); diff --git a/src/s16/client/client.ts b/src/s16/client/client.ts index 2b8cd06f..ba536361 100644 --- a/src/s16/client/client.ts +++ b/src/s16/client/client.ts @@ -1,4 +1,4 @@ -import { clientResponseBytes, type ClientNotificationDevice, type ClientNotificationSettings, type ClientNotificationSubscribeCommand, type ClientSteer, type ClientSteerReceipt, type ClientRoutineDetail, type ClientRoutineDraft, type ClientRoutineList, type ClientRoutineOptions, type ClientRoutineProposal, type ClientRoutineReceipt, type ClientArtifactDetail, type ClientArtifactInputCommand, type ClientArtifactItem, type ClientArtifactList, type ClientArtifactQuery, type ClientMemoryCommand, type ClientMemoryDetail, type ClientMemoryList, type ClientMemoryQuery, type ClientMemoryResult, type ClientBootstrap, type ClientDecision, type ClientEvents, type ClientMessage, type ClientReceipt, type ClientRequest, type ClientSnapshot } from "./contracts"; +import { clientResponseBytes, type ClientBotsView, type ClientBotDetail, type ClientBot, type ClientBotProposal, type ClientBotDraft, type ClientBotExport, type ClientBotCommand, type ClientBotReceipt, type ClientNotificationDevice, type ClientNotificationSettings, type ClientNotificationSubscribeCommand, type ClientSteer, type ClientSteerReceipt, type ClientRoutineDetail, type ClientRoutineDraft, type ClientRoutineList, type ClientRoutineOptions, type ClientRoutineProposal, type ClientRoutineReceipt, type ClientArtifactDetail, type ClientArtifactInputCommand, type ClientArtifactItem, type ClientArtifactList, type ClientArtifactQuery, type ClientMemoryCommand, type ClientMemoryDetail, type ClientMemoryList, type ClientMemoryQuery, type ClientMemoryResult, type ClientBootstrap, type ClientDecision, type ClientEvents, type ClientMessage, type ClientReceipt, type ClientRequest, type ClientSnapshot } from "./contracts"; import type { FileArtifact, FileReference, FileUpload, FileUploadCommand } from "../files/types"; import type { ComputerCommand, ComputerCommandResult, ComputersView } from "../computers/service"; @@ -239,6 +239,47 @@ export class JitClient { && record(source) && ["owner_message", "work", "historical"].includes(source.kind as string) && (source.conversationId === null || identifier(source.conversationId)) && count(source.at) && count(source.files) && link(value.supersedes) && link(value.correction) && count(value.createdAt) && (value.lastUsedAt === null || count(value.lastUsedAt)) && count(value.contexts); } + /** BOT16-01 owner Bots page: profiles, pending proposals and route/resource choices. Reading changes nothing. */ + async bots(signal?: AbortSignal): Promise { + const data = await this.call("/bots", "GET", undefined, signal); + if (!record(data) || data.contract !== "s16-client-bots/v1" || !Array.isArray(data.bots) || data.bots.length > 64 || !data.bots.every(bot => this.botView(bot)) || !Array.isArray(data.proposals) || data.proposals.length > 64 || !data.proposals.every(p => this.botProposal(p)) || !Array.isArray(data.selections) || !Array.isArray(data.resources) || !record(data.creation)) return invalid(); + return data as unknown as ClientBotsView; + } + /** One Bot with its recent conversations and what archiving it would stop and pause. */ + async bot(botId: string, signal?: AbortSignal): Promise { + const data = await this.call(`/bots/${id(botId)}`, "GET", undefined, signal); + if (!record(data) || data.contract !== "s16-client-bot/v1" || !this.botView(data.bot) || !Array.isArray(data.conversations) || !record(data.impact) || !Array.isArray(data.impact.running) || !Array.isArray(data.impact.routines)) return invalid(); + return data as unknown as ClientBotDetail; + } + /** Credential-free configuration export of one Bot. */ + async botExport(botId: string, signal?: AbortSignal): Promise { + const data = await this.call(`/bots/${id(botId)}/export`, "GET", undefined, signal); + if (!record(data) || data.contract !== "s16-bot-export/v1" || typeof data.name !== "string") return invalid(); + return data as unknown as ClientBotExport; + } + /** A new exact proposal from the owner's form or an edited card. Nothing is created until it is accepted. */ + async proposeBot(draft: ClientBotDraft, signal?: AbortSignal): Promise { return this.proposal(await this.call("/bots/proposals", "POST", { draft: structuredClone(draft) }, signal)); } + /** Duplicate copies configuration only, as a new proposal. */ + async duplicateBot(botId: string, name: string, signal?: AbortSignal): Promise { return this.proposal(await this.call(`/bots/${id(botId)}/duplicate`, "POST", { name }, signal)); } + /** Import turns an exported file into a new proposal; references this installation lacks are listed. */ + async importBot(value: unknown, signal?: AbortSignal): Promise { return this.proposal(await this.call("/bots/imports", "POST", { export: value }, signal)); } + /** Exact owner Bot command. It never retries; after an unknown outcome resend the same command (same `requestId`). */ + async botCommand(command: ClientBotCommand, signal?: AbortSignal): Promise { + const input = structuredClone(command); + if (input.contract !== "s16-bot-command/v1" || !identifier(input.requestId)) throw new JitClientError("invalid_bot_command", null, "not_sent", input.requestId); + const data = await this.call("/bots/commands", "POST", input, signal, input.requestId); + if (!record(data) || data.contract !== "s16-bot-receipt/v1" || data.requestId !== input.requestId || data.kind !== input.kind || data.bot !== null && !this.botView(data.bot) || data.proposal !== null && !this.botProposal(data.proposal)) throw new JitClientError("client_protocol_invalid", null, "unknown", input.requestId); + return data as unknown as ClientBotReceipt; + } + private proposal(data: unknown): ClientBotProposal { if (!this.botProposal(data)) return invalid(); return data; } + private botView(value: unknown): value is ClientBot { + return record(value) && identifier(value.id) && count(value.revision) && ["main", "configured", "created"].includes(value.origin as string) && typeof value.main === "boolean" && typeof value.name === "string" && typeof value.responsibility === "string" + && ["active", "archived"].includes(value.state as string) && typeof value.assignments === "boolean" && Array.isArray(value.excluded) && value.excluded.every(identifier); + } + private botProposal(value: unknown): value is ClientBotProposal { + return record(value) && value.contract === "s16-bot-proposal/v1" && identifier(value.id) && sha256(value.proposalHash) && record(value.draft) && value.draft.contract === "s16-bot-draft/v1" && typeof value.draft.name === "string" + && ["pending", "accepted", "dismissed"].includes(value.state as string) && Array.isArray(value.resources) && Array.isArray(value.missing) && record(value.source); + } /** TEAM16-01 working computers: shared/private computers, their observed state and each Bot's computer. Reading starts or stops nothing. */ async computers(signal?: AbortSignal): Promise { const data = await this.call("/computers", "GET", undefined, signal); diff --git a/src/s16/client/contracts.ts b/src/s16/client/contracts.ts index 1977f6e1..fd6de8e0 100644 --- a/src/s16/client/contracts.ts +++ b/src/s16/client/contracts.ts @@ -22,13 +22,18 @@ export interface ClientCapabilities { notifications?: { available: false; code: "notifications_unavailable" } | { available: true; contract: "s16-notifications/v1"; transport: "web_push"; serviceWorker: "/s16/sw.js"; scope: "/s16/" }; /** TEAM16-01 working computers; `files` is FILE16-01C staging/collection inside file work. */ computers?: { available: false; code: "computers_unavailable" } | { available: true; contract: "s16-client-computers/v1"; idleStopMs: number | null; files: boolean }; + /** BOT16-01 persistent Bots (`s16-client-bots/v1`); `delegation` is DELEG16-01 Bot-to-Bot assignments. */ + bots?: { available: false; code: "bots_unavailable" } | { available: true; contract: "s16-client-bots/v1"; delegation: boolean }; } /** Authenticated, no-store bootstrap. CSRF stays in the transport, outside persisted client state. */ export interface ClientBootstrap { contract: "s16-client-bootstrap/v1"; csrf: string; capabilities: ClientCapabilities; - conversations: ConversationListEntry[]; + /** With Bots, each entry also names the Bot the conversation talks to. */ + conversations: (ConversationListEntry & { botId?: string })[]; + /** Additive (BOT16-01): the roster for navigation; names are the owner's own text (empty = the main Bot's default name). */ + bots?: { id: string; name: string; main: boolean; state: "active" | "archived" }[]; needsYou: { conversationId: string; entry: ConversationNeedsYou }[]; /** Additive: unseen non-quiet routine outcomes. Quiet runs never count. */ routines?: { attention: number }; @@ -36,7 +41,7 @@ export interface ClientBootstrap { policyUpdate?: boolean; } /** Currently authorized owner projection; completion cannot be reconstructed from progress events. */ -export interface ClientSnapshot { contract: "s16-client-snapshot/v1"; conversation: ConversationOwnerView } +export interface ClientSnapshot { contract: "s16-client-snapshot/v1"; conversation: ConversationOwnerView; /** Additive (BOT16-01): the conversation's Bot. */ botId?: string } /** One currently authorized request, including requests older than the bounded conversation window. */ export interface ClientRequest { contract: "s16-client-request/v1"; conversationId: string; receipt: ConversationReceipt; row: ConversationOwnerRow } /** Monotonic conversation-bound metadata, used only to invalidate and refresh owner projections. */ @@ -121,3 +126,4 @@ export interface ClientArtifactDetail { contract: "s16-client-artifact/v1"; item export interface ClientArtifactInputCommand { requestId: string; conversationId: string; sha256: string } export type { RoutineDetail as ClientRoutineDetail, RoutineList as ClientRoutineList, RoutineOptions as ClientRoutineOptions, RoutineProposal as ClientRoutineProposal, RoutineReceipt as ClientRoutineReceipt, RoutineDraft as ClientRoutineDraft, RoutineOccurrence as ClientRoutineOccurrence, RoutineSummary as ClientRoutineSummary, RoutineSourceView as ClientRoutineSource } from "../routines/types"; export type { NotificationSettings as ClientNotificationSettings, NotificationDevice as ClientNotificationDevice, NotificationSubscribeCommand as ClientNotificationSubscribeCommand, NotificationPayload as ClientNotificationPayload, NoticeKind as ClientNoticeKind } from "../notifications/types"; +export type { BotsView as ClientBotsView, BotDetail as ClientBotDetail, BotView as ClientBot, BotProposal as ClientBotProposal, BotDraft as ClientBotDraft, BotExport as ClientBotExport, BotCommand as ClientBotCommand, BotReceipt as ClientBotReceipt } from "../bots/types"; diff --git a/src/s16/http.ts b/src/s16/http.ts index b3a763b5..488bbafa 100644 --- a/src/s16/http.ts +++ b/src/s16/http.ts @@ -11,6 +11,7 @@ import { artifactDetail, listArtifacts, parseArtifactQuery } from "./artifacts"; import type { RoutineService } from "./routines/service"; import type { NotificationService } from "./notifications/service"; import type { ComputerDirectory } from "./computers/service"; +import type { BotDirectory } from "./bots/service"; /** * Opaque-origin, script-free rendering of one committed HTML artifact. `sandbox` without tokens disables @@ -20,7 +21,7 @@ import type { ComputerDirectory } from "./computers/service"; export const artifactPreviewPolicy = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'"; /** Protected shared client adapter. It adds no engine, database, approval authority or automatic retry. */ -export function createS16ClientHandler(app: ConversationApplication, options: { origin: string; cookieName: string; /** AUTH16-01 banner: true while a newer default proposal awaits the owner. */ policyUpdate?: (actor: Actor) => boolean; authentication: ConversationHttpAuthentication; files?: FileStore; imageAvailable?: () => boolean; memory?: S16MemoryPort; routines?: RoutineService; wake?: () => void; notifications?: NotificationService; computers?: ComputerDirectory }): (request: Request) => Promise { +export function createS16ClientHandler(app: ConversationApplication, options: { origin: string; cookieName: string; /** AUTH16-01 banner: true while a newer default proposal awaits the owner. */ policyUpdate?: (actor: Actor) => boolean; authentication: ConversationHttpAuthentication; files?: FileStore; imageAvailable?: () => boolean; memory?: S16MemoryPort; routines?: RoutineService; wake?: () => void; notifications?: NotificationService; computers?: ComputerDirectory; /** BOT16-01 persistent Bots (`s16-client-bots/v1`). */ bots?: BotDirectory }): (request: Request) => Promise { const origin = new URL(options.origin); if (origin.origin !== options.origin || !/^[A-Za-z0-9_-]+$/.test(options.cookieName)) throw new AppError("invalid_configuration"); const json = (value: unknown, status = 200): Response => { @@ -52,7 +53,8 @@ export function createS16ClientHandler(app: ConversationApplication memory: options.memory ? { available: true, contract: "s16-client-memory/v1" } : { available: false, code: "memory_unavailable" }, routines: options.routines ? { available: true, contract: "s16-client-routines/v1", reactions: { signed: true, github: true, artifacts: !!options.routines.options.files }, feed: true } : { available: false, code: "routines_unavailable" }, notifications: options.notifications ? { available: true, contract: "s16-notifications/v1", transport: "web_push", serviceWorker: "/s16/sw.js", scope: "/s16/" } : { available: false, code: "notifications_unavailable" }, - computers: options.computers ? { available: true, contract: "s16-client-computers/v1", idleStopMs: options.computers.options.vm.idleStopAfterMs, files: !!(files && app.options.files) } : { available: false, code: "computers_unavailable" } }; + computers: options.computers ? { available: true, contract: "s16-client-computers/v1", idleStopMs: options.computers.options.vm.idleStopAfterMs, files: !!(files && app.options.files) } : { available: false, code: "computers_unavailable" }, + bots: options.bots ? { available: true, contract: "s16-client-bots/v1", delegation: true } : { available: false, code: "bots_unavailable" } }; }; return async request => { try { @@ -167,6 +169,21 @@ export function createS16ClientHandler(app: ConversationApplication if (request.method === "POST" && computer[1] === "commands") { const input = await body(request); return json(await options.computers.command(currentOwner() as Actor & object, input)); } throw new AppError("method_rejected", 405); } + const bot = /^\/api\/s16\/client\/bots(?:\/(proposals|imports|commands|[A-Za-z0-9_-]{1,128})(?:\/(export|duplicate))?)?$/.exec(url.pathname); + if (bot) { + if (!options.bots) throw new AppError("bots_unavailable", 503); + if (url.search) throw new AppError("invalid_body"); + const fixed = ["proposals", "imports", "commands"].includes(bot[1] ?? ""); + if (request.method === "GET" && !bot[1]) return json(options.bots.view(currentOwner())); + if (request.method === "GET" && bot[1] && !fixed && !bot[2]) return json(options.bots.detail(currentOwner(), bot[1])); + if (request.method === "GET" && bot[1] && !fixed && bot[2] === "export") return json(options.bots.exportBot(currentOwner(), bot[1])); + // Body streaming is an async boundary: owner/session and CSRF are checked again after it. + if (request.method === "POST" && bot[1] === "proposals" && !bot[2]) { const input = await body(request); exact(input, ["draft"]); return json(options.bots.propose(currentOwner(), input.draft), 201); } + if (request.method === "POST" && bot[1] === "imports" && !bot[2]) { const input = await body(request); exact(input, ["export"]); return json(options.bots.importBot(currentOwner(), input.export), 201); } + if (request.method === "POST" && bot[1] && !fixed && bot[2] === "duplicate") { const input = await body(request); exact(input, ["name"]); return json(options.bots.duplicate(currentOwner(), bot[1], input.name), 201); } + if (request.method === "POST" && bot[1] === "commands" && !bot[2]) { const input = await body(request); const receipt = options.bots.command(currentOwner(), input); return json(receipt); } + throw new AppError("method_rejected", 405); + } const memory = /^\/api\/s16\/client\/memory(?:\/(commands|[A-Za-z0-9_-]{1,128}))?$/.exec(url.pathname); if (memory) { if (!options.memory) throw new AppError("memory_unavailable", 503); @@ -183,14 +200,15 @@ export function createS16ClientHandler(app: ConversationApplication if (url.pathname === "/api/s16/client/bootstrap" && request.method === "GET") { if (url.search) throw new AppError("invalid_body"); const caps = await capabilities(), current = options.authentication.authenticate(cookies[0]!.slice(options.cookieName.length + 1)); app.options.policy.requireOwner(current.actor); - return json({ contract: "s16-client-bootstrap/v1", csrf: current.csrf, capabilities: caps, conversations: conversationList(app, current.actor, 64), needsYou: conversationPendingNeeds(app, current.actor), ...(options.routines ? { routines: { attention: options.routines.list(current.actor).attention } } : {}), ...(options.policyUpdate?.(current.actor) ? { policyUpdate: true } : {}) } satisfies ClientBootstrap); + const bound = options.bots, conversations = conversationList(app, current.actor, 64).map(entry => bound ? { ...entry, botId: bound.conversationBot(entry.conversationId) } : entry); + return json({ contract: "s16-client-bootstrap/v1", csrf: current.csrf, capabilities: caps, conversations, ...(bound ? { bots: bound.view(current.actor).bots.map(item => ({ id: item.id, name: item.name, main: item.main, state: item.state })) } : {}), needsYou: conversationPendingNeeds(app, current.actor), ...(options.routines ? { routines: { attention: options.routines.list(current.actor).attention } } : {}), ...(options.policyUpdate?.(current.actor) ? { policyUpdate: true } : {}) } satisfies ClientBootstrap); } const conversation = /^\/api\/s16\/client\/conversations\/([A-Za-z0-9_-]{1,128})(?:\/(messages|events|decisions|steers))?$/.exec(url.pathname); if (conversation) { const conversationId = conversation[1]!; if (request.method === "GET" && !conversation[2]) { if (url.search) throw new AppError("invalid_body"); - return json({ contract: "s16-client-snapshot/v1", conversation: conversationOwnerView(app, authenticated.actor, conversationId) }); + return json({ contract: "s16-client-snapshot/v1", conversation: conversationOwnerView(app, authenticated.actor, conversationId), ...(options.bots ? { botId: options.bots.conversationBot(conversationId) } : {}) }); } if (request.method === "GET" && conversation[2] === "events") { if ([...url.searchParams.keys()].some(key => key !== "after") || url.searchParams.getAll("after").length > 1 || !/^(?:0|[1-9]\d{0,15})$/.test(url.searchParams.get("after") ?? "0")) throw new AppError("cursor_invalid", 409); diff --git a/src/s16/notifications/producers.ts b/src/s16/notifications/producers.ts index 1664f987..1f18c339 100644 --- a/src/s16/notifications/producers.ts +++ b/src/s16/notifications/producers.ts @@ -27,7 +27,7 @@ export function conversationNotices(port: { db: Database; row(owner: Notificatio `SELECT e.sequence, e.request_id, e.conversation_id, e.at, e.kind, json_extract(r.record_json,'$.owner.id') AS owner_id, json_extract(r.record_json,'$.owner.generation') AS generation, json_extract(r.record_json,'$.receipt.receivedAt') AS received_at FROM events e JOIN requests r ON r.id=e.request_id - WHERE e.sequence>? AND e.sequence<=? AND e.kind IN ('needs_me','result') AND json_extract(r.record_json,'$.routine') IS NULL ORDER BY e.sequence LIMIT ?`).all(cursor, end, batch); + WHERE e.sequence>? AND e.sequence<=? AND e.kind IN ('needs_me','result') AND json_extract(r.record_json,'$.routine') IS NULL AND (e.kind='needs_me' OR json_type(r.record_json,'$.delegation') IS NULL) ORDER BY e.sequence LIMIT ?`).all(cursor, end, batch); const notices: NoticeInput[] = []; for (const event of rows) { const owner = { ownerId: event.owner_id, generation: event.generation }; diff --git a/src/s16/web/BotProposalCard.tsx b/src/s16/web/BotProposalCard.tsx new file mode 100644 index 00000000..55a473e3 --- /dev/null +++ b/src/s16/web/BotProposalCard.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { JitClientError, type JitClient } from "../client"; +import type { ClientBotProposal, ClientBotReceipt, ClientBotsView } from "../client/contracts"; +import type { Copy } from "./copy"; + +const uid = () => crypto.randomUUID().replaceAll("-", ""); +const privateFailure = (error: unknown) => error instanceof JitClientError && ([401, 403].includes(error.status ?? 0) || ["authentication_required", "authorization_required", "csrf_rejected"].includes(error.code)); + +/** Props for one exact Bot proposal. Choices come from the same protected Bots page read. */ +export interface BotProposalCardProps { + client: JitClient; t: Copy; proposal: ClientBotProposal; + choices: Pick | null; + onAccepted(receipt: ClientBotReceipt): void; + /** Opens the editable form prefilled with this draft; an edit becomes a new proposal. */ + onEdit?(proposal: ClientBotProposal): void; + onDismissed?(): void; + onFailure(error: unknown): void; +} + +/** + * BOT16-01 proposal card (chat and Bots page). One press accepts exactly the shown revision (`proposalHash`); nothing + * is created before it, and any edit makes a new proposal. An uncertain response is checked again with the same + * command identity, never sent as a new acceptance. + */ +export function BotProposalCard({ client, t, proposal, choices, onAccepted, onEdit, onDismissed, onFailure }: BotProposalCardProps) { + const [state, setState] = useState<{ kind: "idle" } | { kind: "pending" } | { kind: "error"; code: string } | { kind: "unknown"; requestId: string }>({ kind: "idle" }); + const draft = proposal.draft; + const model = draft.selection ? choices?.selections.find(choice => JSON.stringify(choice.selection) === JSON.stringify(draft.selection))?.label ?? null : null; + const uses = proposal.resources.filter(resource => !draft.excluded.includes(resource.id)); + async function accept(requestId = uid()) { + setState({ kind: "pending" }); + try { onAccepted(await client.botCommand({ contract: "s16-bot-command/v1", requestId, kind: "bot.accept", proposalId: proposal.id, proposalHash: proposal.proposalHash })); setState({ kind: "idle" }); } + catch (error) { + if (privateFailure(error)) { onFailure(error); return; } + setState(error instanceof JitClientError && error.outcome === "unknown" ? { kind: "unknown", requestId } : { kind: "error", code: error instanceof JitClientError ? error.code : "client_unavailable" }); + } + } + async function dismiss() { + setState({ kind: "pending" }); + try { await client.botCommand({ contract: "s16-bot-command/v1", requestId: uid(), kind: "bot.dismiss", proposalId: proposal.id }); onDismissed?.(); } + catch (error) { if (privateFailure(error)) { onFailure(error); return; } setState({ kind: "error", code: error instanceof JitClientError ? error.code : "client_unavailable" }); } + } + const busy = state.kind === "pending"; + return
+

{t("s16.bots.proposalTitle", { name: draft.name })}

+ {proposal.source.kind === "chat" &&

{t("s16.bots.proposalChat")}

} + {proposal.source.kind === "import" &&

{t("s16.bots.proposalImport")}

} + {proposal.source.kind === "duplicate" &&

{t("s16.bots.proposalDuplicate")}

} +
+ {draft.responsibility && <>
{t("s16.bots.responsibility")}
{draft.responsibility}
} +
{t("s16.bots.model")}
{model ?? t("s16.bots.modelDefault")}
+
{t("s16.bots.resources")}
{uses.length ? uses.map(resource => resource.label).join(", ") : t("s16.bots.noResources")}
+
+ {!!proposal.missing.length &&

{t("s16.bots.missing", { items: proposal.missing.join(", ") })}

} +

{t("s16.bots.proposalBody")}

+
+ {state.kind === "error" &&

{state.code === "stale_proposal" ? t("s16.bots.stale") : t("s16.bots.failed", { reason: state.code })}

} + {state.kind === "unknown" &&

{t("s16.bots.unknown")}

} +
+
+ + {onEdit && } + +
+
; +} diff --git a/src/s16/web/BotsView.tsx b/src/s16/web/BotsView.tsx new file mode 100644 index 00000000..8a7009e9 --- /dev/null +++ b/src/s16/web/BotsView.tsx @@ -0,0 +1,201 @@ +import { useEffect, useRef, useState } from "react"; +import type { Locale } from "../../i18n"; +import { JitClientError, type JitClient } from "../client"; +import type { ClientBot, ClientBotCommand, ClientBotDetail, ClientBotDraft, ClientBotProposal, ClientBotReceipt, ClientBotsView } from "../client/contracts"; +import { BotProposalCard } from "./BotProposalCard"; +import type { Copy } from "./copy"; + +/** Which Bots page is open. `new` may carry a proposal to edit (its draft prefills the form). */ +export type BotsPage = { kind: "list" } | { kind: "bot"; id: string } | { kind: "new"; seed?: ClientBotDraft }; +/** Props for the owner Bots surface (BOT16-01). Data comes only from the protected client. */ +export interface BotsViewProps { + client: JitClient; t: Copy; locale: Locale; + /** Server capability. When false, no request is made and no control is shown. */ + available: boolean; + page: BotsPage; + onNavigate(page: BotsPage): void; + onStartChat(botId: string): void; + onOpenConversation(conversationId: string): void; + /** The roster changed (created, renamed, archived, restored); navigation should refresh. */ + onChanged(): void; + onFailure(error: unknown): void; +} +type Notice = { kind: "pending" } | { kind: "done"; text: string } | { kind: "error"; code: string } | { kind: "unknown"; command: ClientBotCommand }; +const uid = () => crypto.randomUUID().replaceAll("-", ""); +const code = (error: unknown) => error instanceof JitClientError ? error.code : "client_unavailable"; +const privateFailure = (error: unknown) => error instanceof JitClientError && ([401, 403].includes(error.status ?? 0) || ["authentication_required", "authorization_required", "csrf_rejected"].includes(error.code)); +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +/** Owner-facing Bot name: the main Bot's default name is localized; every other name is the owner's own text. */ +export function botName(t: Copy, bot: Pick | undefined | null): string { return !bot ? "" : bot.main && !bot.name ? t("s16.bots.mainName") : bot.name; } + +/** + * Owner Bots: roster, pending proposals, one Bot's profile and lifecycle (archive/restore/duplicate/export) and the + * New Bot form. Every change is one explicit command that is never retried silently; creating a Bot always goes + * through the reviewed proposal card. + */ +export function BotsView({ client, t, locale, available, page, onNavigate, onStartChat, onOpenConversation, onChanged, onFailure }: BotsViewProps) { + const [data, setData] = useState(null), [detail, setDetail] = useState(null), [loadError, setLoadError] = useState(null); + const [notice, setNotice] = useState(null), [reload, setReload] = useState(0), [proposal, setProposal] = useState(null), [confirm, setConfirm] = useState(false); + const noticeRef = useRef(null), heading = useRef(null), importer = useRef(null), commands = useRef(0); + const date = (value: number) => new Intl.DateTimeFormat(locale === "th" ? "th-TH" : "en-GB", { dateStyle: "medium" }).format(value); + const fail = (error: unknown) => { if (privateFailure(error)) { onFailure(error); return true; } return false; }; + + useEffect(() => { + if (!available) return; + const stop = new AbortController(), turn = ++commands.current; + setDetail(null); setProposal(null); setConfirm(false); + (async () => { + try { + const next = await client.bots(stop.signal); if (turn !== commands.current) return; + setData(next); setLoadError(null); + if (page.kind === "bot") { const one = await client.bot(page.id, stop.signal); if (turn === commands.current) setDetail(one); } + } catch (error) { if (stop.signal.aborted || code(error) === "client_response_stale" || fail(error)) return; setLoadError(code(error)); } + })(); + return () => stop.abort(); + }, [available, reload, page.kind, page.kind === "bot" ? page.id : ""]); + useEffect(() => { heading.current?.focus(); }, [page.kind, page.kind === "bot" ? page.id : ""]); + useEffect(() => { if (notice && notice.kind !== "pending") noticeRef.current?.focus(); }, [notice]); + + async function send(command: ClientBotCommand, done: (receipt: ClientBotReceipt) => string) { + setNotice({ kind: "pending" }); + try { const receipt = await client.botCommand(command); setNotice({ kind: "done", text: done(receipt) }); onChanged(); setReload(value => value + 1); } + catch (error) { if (fail(error)) return; setNotice(error instanceof JitClientError && error.outcome === "unknown" ? { kind: "unknown", command } : { kind: "error", code: code(error) }); } + } + async function review(draft: ClientBotDraft) { + setNotice({ kind: "pending" }); + try { setProposal(await client.proposeBot(draft)); setNotice(null); } + catch (error) { if (fail(error)) return; setNotice({ kind: "error", code: code(error) }); } + } + async function duplicate(bot: ClientBot) { + setNotice({ kind: "pending" }); + try { setProposal(await client.duplicateBot(bot.id, t("s16.bots.copyName", { name: botName(t, bot) }).slice(0, 60))); setNotice(null); } + catch (error) { if (fail(error)) return; setNotice({ kind: "error", code: code(error) }); } + } + async function exportBot(bot: ClientBot) { + try { + const file = await client.botExport(bot.id); + const url = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: "application/json" })), link = document.createElement("a"); + link.href = url; link.download = `${(botName(t, bot) || "bot").replace(/[^\p{L}\p{N}_-]+/gu, "-").slice(0, 40)}.jit-bot.json`; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); + } catch (error) { if (fail(error)) return; setNotice({ kind: "error", code: code(error) }); } + } + async function importFile(file: File | undefined) { + if (!file) return; + if (file.size > 16384) { setNotice({ kind: "error", code: "body_too_large" }); return; } + let parsed: unknown; + try { parsed = JSON.parse(await file.text()); } catch { setNotice({ kind: "error", code: "invalid_bot_export" }); return; } + setNotice({ kind: "pending" }); + try { setProposal(await client.importBot(parsed)); setNotice(null); } + catch (error) { if (fail(error)) return; setNotice({ kind: "error", code: code(error) }); } + } + const accepted = (receipt: ClientBotReceipt) => { setProposal(null); onChanged(); setNotice({ kind: "done", text: t("s16.bots.created", { name: receipt.bot ? botName(t, receipt.bot) : "" }) }); if (receipt.bot) onNavigate({ kind: "bot", id: receipt.bot.id }); else setReload(value => value + 1); }; + + if (!available) return

{t("s16.bots.title")}

{t("s16.bots.unavailable")}

; + const busy = notice?.kind === "pending"; + const status =
+ {notice?.kind === "done" &&

{notice.text}

} + {notice?.kind === "error" &&

{notice.code === "stale_proposal" ? t("s16.bots.stale") : t("s16.bots.failed", { reason: notice.code })}

} + {notice?.kind === "unknown" &&

{t("s16.bots.unknown")}

} +
; + const proposalCard = (item: ClientBotProposal) => { setProposal(null); onNavigate({ kind: "new", seed: value.draft }); }} onDismissed={() => { setProposal(null); setReload(value => value + 1); }} onFailure={onFailure} />; + const loading = <>{!data && !loadError &&

{t("s16.common.loading")}

} + {loadError &&

{t("s16.bots.loadFailed", { reason: loadError })}

}; + + if (page.kind === "new") return
+ +

{t("s16.bots.new")}

+ {loading}{status} + {data && void review(draft)} />} + {proposal && proposalCard(proposal)} +
; + + if (page.kind === "bot") { + const bot = detail?.bot; + return
+ +

{bot ? botName(t, bot) : t("s16.bots.title")}

+ {loading}{status} + {data && !detail && !loadError &&

{t("s16.common.loading")}

} + {bot && data && <> +

{bot.main && {t("s16.bots.mainBadge")}} {bot.state === "archived" && {t("s16.bots.archived")}}

+ {bot.selectionUnavailable &&

{t("s16.bots.modelUnavailable")}

} + {bot.state === "active" ? void send({ contract: "s16-bot-command/v1", requestId: uid(), kind: "bot.update", botId: bot.id, expectedRevision: bot.revision, name: draft.name, responsibility: draft.responsibility, selection: draft.selection, assignments: draft.assignments }, () => t("s16.bots.saved"))} /> + : bot.responsibility &&

{bot.responsibility}

} +
+ {bot.state === "active" && } + {data.creation.available && } + + {!bot.main && bot.state === "active" && } + {bot.state === "archived" && } +
+ {bot.main &&

{t("s16.bots.mainFixed")}

} + {confirm &&
{ if (event.key === "Escape") { event.stopPropagation(); setConfirm(false); } }}> +

{t("s16.bots.archiveTitle", { name: botName(t, bot) })}

+

{t("s16.bots.archiveBody", { name: botName(t, bot) })}

+ {!!detail.impact.running.length &&

{t("s16.bots.archiveRunning", { n: detail.impact.running.length })}

} + {!!detail.impact.routines.length &&

{t("s16.bots.archiveRoutines", { names: detail.impact.routines.map(item => item.name).join(", ") })}

} + + +
} + {proposal && proposalCard(proposal)} +

{t("s16.bots.conversations")}

+ {!detail.conversations.length &&

{bot.main ? t("s16.bots.noConversations") : t("s16.bots.noConversations")}

} +
    {detail.conversations.map(item =>
  • {date(item.latestAt)}
  • )}
+ } +
; + } + + return
+

{t("s16.bots.title")}

+

{t("s16.bots.lead")}

+ {loading}{status} + {data && <> +
+ {data.creation.available ? :

{t("s16.bots.creationUnavailable", { reason: data.creation.code })}

} + {data.creation.available && <> + { void importFile(event.target.files?.[0]); event.target.value = ""; }} />} +
+ {proposal && proposalCard(proposal)} + {!!data.proposals.length && <>

{t("s16.bots.pending")}

{data.proposals.filter(item => item.id !== proposal?.id).map(proposalCard)}} +
    {data.bots.map(bot =>
  • + +
  • )}
+ } +
; +} + +/** The exact editable Bot configuration; submitting creates a proposal (new Bot) or saves the profile (existing Bot). */ +function BotForm({ t, data, initial, busy, submit, creating = false, main = false, onSubmit }: { t: Copy; data: ClientBotsView; initial: ClientBotDraft | null; busy: boolean; submit: string; creating?: boolean; main?: boolean; onSubmit(draft: ClientBotDraft): void }) { + const [name, setName] = useState(initial?.name ?? ""), [responsibility, setResponsibility] = useState(initial?.responsibility ?? ""); + const [selection, setSelection] = useState(() => initial?.selection ? data.selections.findIndex(choice => same(choice.selection, initial.selection)) : -1); + const [excluded, setExcluded] = useState(initial?.excluded ?? []), [assignments, setAssignments] = useState(initial?.assignments ?? true), [invalid, setInvalid] = useState(false); + return
{ + event.preventDefault(); + if (!name.trim()) { setInvalid(true); return; } + onSubmit({ contract: "s16-bot-draft/v1", name: name.trim(), responsibility: responsibility.trim(), selection: selection >= 0 ? data.selections[selection]!.selection : null, excluded: [...excluded].sort(), assignments }); + }}> + + setName(event.target.value)} /> + {invalid && !name.trim() &&

{t("s16.bots.nameRequired")}

} + +