From b65e5cae0e5d27ac07db00a4ac5a9d0bbaeef428 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Thu, 2 Jul 2026 08:12:30 -0700 Subject: [PATCH] =?UTF-8?q?docs(personas):=20accuracy=20sweep=20=E2=80=94?= =?UTF-8?q?=20spec=20models,=20protocol=20contracts,=20page=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec models now describe what the endpoints do: ListPersonasResponse declares the tool_inventory the shelf depends on, both console create models declare persona, CreatePersonaRequest declares org_id, and UpdatePersonaRequest documents the null-vs-absent split (null clears base_prompt/tool_allowlist, null on flags/kinds is ignored). Console OpenAPI regenerated. Protocol contracts match the implementations: update_persona's return covers the no-op case, create_persona's raises-list is complete, and both extended row-shape docstrings gain their tail columns plus the append-only rule. The workstreams.persona comments say slug, not display name. Page corrections from the docs review: personas.md documents the creative_mode-to-writer migration conversion, the mid-session /resume MCP-lever behavior, visibility-based nudge gating, the soft-set prompt-cache cost, and the executive tool list — and drops internal jargon. The changelog entry moves under [Unreleased] with the house breaking-marker style and the auto-conversion note. coordinator-skills and the API tour stop using persona to mean framing; governance, api-reference, sdk, console, tools, and memory pick up the new permission family, endpoints, kwargs, picker, and lever caveats. --- CHANGELOG.md | 14 +++++++---- docs/api-reference.md | 37 +++++++++++++++++++++++++++++ docs/console.md | 7 +++--- docs/coordinator-api-tour.md | 2 +- docs/coordinator-skills.md | 16 ++++++------- docs/governance.md | 11 ++++++--- docs/memory.md | 5 ++++ docs/personas.md | 22 ++++++++++++----- docs/sdk.md | 4 ++-- docs/tools.md | 5 ++++ sdk/typescript/openapi-console.json | 31 +++++++++++++++++++++++- turnstone/api/console_schemas.py | 26 +++++++++++++++++++- turnstone/core/storage/_protocol.py | 32 ++++++++++++++++--------- turnstone/core/storage/_schema.py | 9 +++---- 14 files changed, 177 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 480662d4..31bcd86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,9 @@ stable, and the experimental line: - **`stable/1.6`** — patch-only (`v1.6.x`) - **`main`** — experimental (next major) -## [Unreleased — 1.7] +## [Unreleased] -### Personas +### Added - **Personas** (#683) — a named, reusable bundle attached to a workstream at creation, controlling system-message composition and the capability @@ -31,13 +31,19 @@ stable, and the experimental line: `turnstone --persona `); authored in the console's new Governance → Personas tab (`persona.{create,read,write}` perms, archive-only lifecycle). See `docs/personas.md`. -- **BREAKING: `/creative` removed.** The REPL toggle (and its tab + +### Removed + +- **`/creative` removed** *(BREAKING)* — the REPL toggle (and its tab completion) is gone; the `writer` seed persona replaces it — start a session with `turnstone --persona writer` or pick *Writer* in the web pickers. Unlike the old fork, the writer persona composes the full system message, so session context and mandatory prompt policies now apply to prose-only sessions too. The `creative_mode` key in - `workstream_config` is no longer read or written. + `workstream_config` is no longer read or written. Migration `063` + converts existing creative-mode workstreams to the `writer` persona + automatically, so they resume as writing sessions rather than as + legacy defaults. ## [1.6.0] diff --git a/docs/api-reference.md b/docs/api-reference.md index 4bf16f17..5fa207bc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -698,6 +698,42 @@ Each skill summary: --- +### `GET /v1/api/personas` + +Returns the enabled personas offered by the workstream-creation pickers. +Authenticated for any logged-in user and deliberately gated by **no** +`persona.*` permission — selecting a persona at creation is a user +action, while the `persona.*` perms gate authoring. Display fields only; +the levers (base prompt, tool set, MCP/memory toggles) stay server-side. + +**Response:** + +```json +{ + "personas": [ + {"name": "engineer", "display_name": "Engineer", "description": "The stock interactive workstream: full tools, MCP, and memory.", "applies_to_kinds": ["interactive"], "is_default": true}, + {"name": "researcher", "display_name": "Researcher", "description": "Answers questions with evidence, read-only. Never modifies anything.", "applies_to_kinds": ["interactive"], "is_default": false} + ], + "total": 2 +} +``` + +Each persona summary: + +| Field | Type | Description | +|--------------------|--------|------------------------------------------------------------------| +| `name` | string | Persona slug (used in the `persona` field on workstream creation) | +| `display_name` | string | Human-readable label for pickers | +| `description` | string | Short description of the persona's intent | +| `applies_to_kinds` | array | Workstream kinds the persona applies to (`interactive` / `coordinator`) | +| `is_default` | bool | Whether this is the default persona for its kind | + +> **Note:** For full persona management (create, edit, archive), use the +> admin endpoints at `/v1/api/admin/personas` (requires the +> `persona.{create,read,write}` permissions). + +--- + ### `POST /v1/api/workstreams/{ws_id}/send` Sends a user message to a workstream. Spawns a daemon worker thread that calls @@ -895,6 +931,7 @@ All fields are optional. The body can be empty or an empty JSON object. | `auto_approve` | bool | false | Auto-approve all tool calls for this workstream | | `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)| | `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). | +| `persona` | string | "" | Persona slug. Resolved and snapshotted into the workstream at creation; empty selects the kind's default. | | `judge_model` | string | "" | Optional model alias for the judge (overrides default judge model for this workstream) | > **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream. diff --git a/docs/console.md b/docs/console.md index c54e5d64..fb052efd 100644 --- a/docs/console.md +++ b/docs/console.md @@ -379,6 +379,7 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated Triggered by the "+ new" header button. A modal dialog with: - **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" picks a node with available capacity using round-robin, or a specific node from the list (showing capacity). +- **Persona** — optional dropdown listing the enabled personas for the workstream kind. Sets the system-message composition and capability envelope at creation, snapshotted server-side; empty uses the kind's default. Picking one requires no `persona.*` permission. - **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time. - **Name** — optional text input. Auto-generated if left empty. - **Model** — optional text input for a model alias from the target node's registry. @@ -396,9 +397,9 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna Accessed via the "admin" button in the header (visible when authenticated with `approve` scope). Provides user, API token, channel link, MCP server, -and skill management with 18 tabs (Users, API Tokens, Channels, Schedules, -Watches, Roles, Policies, Prompts, Judge, Skills, MCP Servers, Usage, -Audit, Memories, Models, Nodes, Settings, TLS). See also +and skill management with tabs that include Users, API Tokens, Channels, +Schedules, Watches, Personas, Roles, Policies, Prompts, Judge, Skills, +MCP Servers, Usage, Audit, Memories, Models, Nodes, Settings, and TLS. See also [Governance](governance.md) for the Roles, Policies, Skills, Usage, and Audit tabs, and [Settings](settings.md) for the database-backed configuration editor. diff --git a/docs/coordinator-api-tour.md b/docs/coordinator-api-tour.md index 991b8190..4042af18 100644 --- a/docs/coordinator-api-tour.md +++ b/docs/coordinator-api-tour.md @@ -366,7 +366,7 @@ deleted. ## Further reading - [coordinator-skills.md](coordinator-skills.md) — writing a skill - that runs on a coordinator session (orchestrator persona, + that runs on a coordinator session (orchestrator framing, workflow patterns, `SkillKind` classifier). - [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape idioms (`{results, denied, truncated}` vs diff --git a/docs/coordinator-skills.md b/docs/coordinator-skills.md index e66c9ece..2b08f726 100644 --- a/docs/coordinator-skills.md +++ b/docs/coordinator-skills.md @@ -1,13 +1,13 @@ # Writing a coordinator-specific skill -Skills are prompt-level personas that steer a Turnstone session +A skill is prompt-level framing that steers a Turnstone session toward a narrow task. Most skills target **interactive** sessions — the single-workstream "do this thing" surface where the model wields `bash`, `edit_file`, `web_fetch`, and the rest of the maker toolset. A **coordinator skill** is different. It runs on a session whose job is to orchestrate other sessions. The toolset is smaller and -narrower, the persona is an orchestrator instead of a maker, and the +narrower, the role is an orchestrator instead of a maker, and the success metric is "did the plan resolve" instead of "did the code compile". This doc covers the differences a skill author has to care about. @@ -22,8 +22,8 @@ migration 044 added the column). Three values: | `SkillKind` enum | Stored as | Meaning | |-------------------------|-----------------|----------------------------------------------------------------------------| -| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker persona (single-workstream "do this"). | -| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator persona (delegate, monitor, synthesise). | +| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker role (single-workstream "do this"). | +| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator role (delegate, monitor, synthesise). | | `SkillKind.ANY` | `"any"` | Either surface (or audience-neutral). Default on create. | The `kind` field is a `StrEnum` — drop-in `str` compatible — so DB @@ -96,15 +96,15 @@ for the output. The coordinator stays the orchestrator. --- -## Persona differences +## Framing differences Interactive skills compose on top of `base_interactive.md` — a -"maker" persona: get the work done, use the tools, edit the code, +"maker" framing: get the work done, use the tools, edit the code, close the loop. Coordinator skills compose on top of [`base_coordinator.md`](../turnstone/prompts/base_coordinator.md) — -an "orchestrator" persona: decompose, delegate, monitor, synthesise. +an "orchestrator" framing: decompose, delegate, monitor, synthesise. The base text is short but sets the tone every coordinator skill inherits: @@ -339,7 +339,7 @@ For a new coordinator skill: A full end-to-end test isn't required for every skill; a prepare-step unit test that asserts "given this initial message, the first tool call is X with Y args" is usually sufficient to catch -persona drift without a real LLM in the loop. +framing drift without a real LLM in the loop. --- diff --git a/docs/governance.md b/docs/governance.md index a6be998b..4895eb5a 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -13,7 +13,7 @@ The permission model has two layers: 1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware` on every request based on URL path classification. -2. **Permissions** (granular) — 15 permission strings checked per-endpoint by +2. **Permissions** (granular) — named permission strings checked per-endpoint by `require_permission()`. **Built-in roles** (seeded by migration 008): @@ -24,7 +24,11 @@ The permission model has two layers: | operator | read, write, workstreams.create, workstreams.close | | viewer | read | -Custom roles can be created with any subset of the 15 valid permissions. +Custom roles can be created with any subset of the valid permissions. +The `persona.create` / `persona.read` / `persona.write` family gates +persona administration; migration `063` seeds all three onto +`builtin-admin`, and any role can be granted them through the standard +role and permission-override editors. **Auth flow:** 1. User logs in (password or API token) → `_load_user_permissions()` aggregates @@ -177,6 +181,7 @@ All under `/v1/api/admin/` (requires `approve` scope + granular permission). | Orgs | 3 (list, get, update) | `admin.orgs` | | Tool Policies | 4 (CRUD) | `admin.policies` | | Skills | 4 (CRUD) | `admin.skills` | +| Personas | 4 (list, create, get, edit/archive) | `persona.read` / `persona.create` / `persona.write` | | Schedules | 6 (CRUD + runs) | `admin.schedules` | | Watches | 3 (list, create, cancel) | `admin.watches` | | Usage | 1 (aggregated query) | `admin.usage` | @@ -222,7 +227,7 @@ Both Python and TypeScript console SDKs expose governance methods: - **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment and requires caller to hold a superset of the target role's permissions - **Permission validation**: Role create/update validates permissions against - a 15-item allowlist (`_VALID_PERMISSIONS`) + the permission allowlist (`_VALID_PERMISSIONS`) - **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete your own account (matching the self-assignment guard on role endpoints) - **Field allowlists**: Storage `update_*` methods filter fields against diff --git a/docs/memory.md b/docs/memory.md index 9085f986..137e2fb4 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -75,6 +75,11 @@ This means the model always has its most relevant memories available without explicit recall -- but can still use `memory(action='search')` for deeper lookup. +The persona memory lever gates this pathway: a workstream whose persona +turns memory off receives no relevance injection at all -- the steps +above run only when memory is enabled for the session. See +[Personas](personas.md). + ### Nudges The metacognition layer can nudge the model to save memories at appropriate diff --git a/docs/personas.md b/docs/personas.md index 1b7425fd..4fc8b262 100644 --- a/docs/personas.md +++ b/docs/personas.md @@ -12,9 +12,9 @@ A persona is exactly four levers — no more: | Lever | What it does | |---|---| | **Base prompt** | Replaces the BASE module of the composed system message (`base.md` / `base_coordinator.md`). *Only* BASE: ENV, CONTEXT, TOOLS, and POLICIES keep composing, so mandatory [prompt policies](governance.md) ride on top of every persona. Empty = the kind's stock base. | -| **Tool visibility** | Which tools the session advertises. Tri-state: *unrestricted* (tracks tool growth and MCP catalogs), *no tools* (the TOOLS prompt block self-suppresses and zero definitions go on the wire), or an *exact set* of names. Including `tool_search` in a set makes it **soft** — tools the model discovers through search join the visible set; omitting it makes the set **hard** (the search pathway is disabled entirely). | +| **Tool visibility** | Which tools the session advertises. Tri-state: *unrestricted* (tracks tool growth and MCP catalogs), *no tools* (the TOOLS prompt block self-suppresses and zero definitions go on the wire), or an *exact set* of names. Including `tool_search` in a set makes it **soft** — tools the model discovers through search join the visible set; omitting it makes the set **hard** (the search pathway is disabled entirely). On commercial providers a soft set costs one prompt-cache re-prime per `tool_search` expansion, since each expansion rewrites the wire tool set and recomposes the prompt. | | **MCP** | Whether the workstream talks to MCP at all. **Session-wide**: off means no MCP tools for the persona's own hands *or* for in-process task agents, no resource/prompt catalogs, and no listener registrations. This lever expresses infrastructure intent, not behavior shaping. | -| **Memory** | Whether the persona's **own hands** get memory: recalled-memory injection into the prompt, memory-directed metacognitive nudges, and the `memory` tool. Task agents keep their own envelope, and compaction spill/markers are session mechanics that are never persona-gated. | +| **Memory** | Whether the persona's **own hands** get memory: recalled-memory injection into the prompt, memory-directed metacognitive nudges, and the `memory` tool. Task agents keep their own envelope, and compaction spill/markers are session mechanics that are never persona-gated. An exact tool set that hides `memory` also mutes those nudges, and the compaction-resume pointer follows `recall`'s visibility. | Visibility is behavior shaping, **not** a security boundary: any tool call that does reach the wire still clears the same approval, judge, and policy @@ -29,6 +29,12 @@ reads only the stamp: - **Editing or archiving a persona never changes an existing workstream.** Rehydrate, resume, and post-compaction resume all run from the stamp. + A mid-session REPL `/resume` adopts the target workstream's stamp for + prompt, tools, and memory; for the MCP lever it can only narrow in + place — adopting an MCP-off stamp drops the live MCP surface, while + adopting an MCP-on stamp into a session whose persona dropped MCP at + construction is refused with an error telling you to reopen the + workstream fresh. - A workstream outlives its persona — an archived persona keeps labelling the workstreams stamped with it. - A partial or unparseable stamp is treated as corruption: session @@ -36,7 +42,10 @@ reads only the stamp: envelope the operator never chose. - Workstreams created before personas existed carry no stamp and keep legacy behavior, byte-identical to the `engineer` / `orchestrator` - defaults below. + defaults below — with one exception: pre-1.7 workstreams that had + `creative_mode` set are converted by migration `063` into full + `writer` stamps, so they resume as writing sessions rather than as + legacy defaults. - Forking (`resume_ws` on create) resumes the source's stamped persona; the fork does not re-resolve. @@ -53,7 +62,7 @@ personas existed: | `scribe` | interactive | custom (faithful structuring of given material) | none | off | off | | `researcher` | interactive | custom (evidence-first, read-only) | `read_file`, `search`, `web_fetch`, `web_search`, `recall`, `memory` (hard) | off | on | | `writer` | interactive | custom (creative writing partner — replaces the removed `/creative`) | none | off | on | -| `executive` | coordinator | custom (delegate, interrogate plans, judge outcomes) | 10 delegation/inspection tools (hard) | off | on | +| `executive` | coordinator | custom (delegate, interrogate plans, judge outcomes) | spawn/inspect/lifecycle tools plus `memory`: `spawn_workstream`, `spawn_batch`, `send_to_workstream`, `wait_for_workstream`, `inspect_workstream`, `list_workstreams`, `list_nodes`, `close_workstream`, `cancel_workstream`, `memory` (hard) | off | on | Notes: @@ -82,7 +91,8 @@ seeded): startup. `--resume` ignores `--persona` and adopts the resumed workstream's stamp. - **Coordinator spawn**: `spawn_workstream` / `spawn_batch` take a - `persona` argument, validated at prep time (children are always + `persona` argument, validated when the coordinator prepares the spawn + and re-checked by the node that creates the child (children are always interactive-kind). Omitted means the interactive **default** — a child never inherits its parent coordinator's persona. Sub-agents spawned via `task_agent` have no persona parameter at all; they keep their own @@ -91,7 +101,7 @@ seeded): ## Authoring (console) Personas are managed in the console's **Manage → Governance → Personas** -tab. The Service Hatch shelf exposes exactly the four levers plus the kind +tab. The admin shelf exposes exactly the four levers plus the kind list, the default marker, and archive. Rules: - `name` is an immutable lowercase slug; edit `display_name` instead. diff --git a/docs/sdk.md b/docs/sdk.md index 884a64be..bb49de06 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -69,7 +69,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose: |----------|--------|---------| | **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` | | | `dashboard()` | `DashboardResponse` | -| | `create_workstream(*, name, model, auto_approve, skill, initial_message, attachments)` | `CreateWorkstreamResponse` | +| | `create_workstream(*, name, model, auto_approve, skill, persona, initial_message, attachments)` | `CreateWorkstreamResponse` | | | `close_workstream(ws_id)` | `StatusResponse` | | **Attachments** | `upload_attachment(ws_id, filename, data, *, mime_type=...)` | `UploadAttachmentResponse` | | | `list_attachments(ws_id)` | `ListAttachmentsResponse` | @@ -100,7 +100,7 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose: | | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` | | | `node_detail(node_id)` | `NodeDetailResponse` | | | `snapshot()` | `ClusterSnapshotResponse` | -| | `create_workstream(*, node_id, name, model, initial_message, skill)` | `ConsoleCreateWsResponse` | +| | `create_workstream(*, node_id, name, model, initial_message, skill, persona)` | `ConsoleCreateWsResponse` | | **Schedules** | `list_schedules()` | `ListSchedulesResponse` | | | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` | | | `get_schedule(task_id)` | `ScheduleInfo` | diff --git a/docs/tools.md b/docs/tools.md index f74edc7c..ebbf885d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -580,6 +580,11 @@ Tool search uses the best available mechanism for each provider: `_exec_tool_search()` runs a pure-Python BM25 index over tool names and descriptions, then expands the matched tools into the visible set. +A persona with a tool-visibility set overrides this selection: any exact +set forces tool search into the client-side BM25 mechanism (tier 3) +regardless of provider, and a **hard** set — one whose visible tools omit +`tool_search` — disables tool search entirely. + ### Configuration Tool search is configured in `config.toml` under the `[tools]` section: diff --git a/sdk/typescript/openapi-console.json b/sdk/typescript/openapi-console.json index 0a4f8e66..1043bf2c 100644 --- a/sdk/typescript/openapi-console.json +++ b/sdk/typescript/openapi-console.json @@ -7785,6 +7785,12 @@ "title": "Skill", "type": "string" }, + "persona": { + "default": "", + "description": "Persona slug; resolved and snapshotted at creation, empty = kind default", + "title": "Persona", + "type": "string" + }, "resume_ws": { "default": "", "description": "Workstream ID to resume (loads previous conversation)", @@ -8112,6 +8118,12 @@ "description": "Optional skill name to apply to the coordinator session.", "title": "Skill" }, + "persona": { + "default": "", + "description": "Persona slug; resolved and snapshotted at creation, empty = kind default", + "title": "Persona", + "type": "string" + }, "initial_message": { "default": "", "description": "Optional first user message dispatched to the new coordinator session.", @@ -11232,6 +11244,12 @@ "default": true, "title": "Enabled", "type": "boolean" + }, + "org_id": { + "default": "", + "description": "Owning org (informational; capped at 64)", + "title": "Org Id", + "type": "string" } }, "required": [ @@ -11241,7 +11259,7 @@ "type": "object" }, "UpdatePersonaRequest": { - "description": "PATCH body \u2014 only fields present in the JSON are applied.\n\nArchive = ``{\"enabled\": false}``; default flip = ``{\"is_default\": true}``\non the successor (storage demotes the incumbent atomically). ``name``\nis immutable; existing workstreams are never affected by edits.", + "description": "PATCH body \u2014 absent fields are left unchanged.\n\nExplicit ``null`` is meaningful only on the two resettable fields:\n``base_prompt: null`` clears the override back to the kind's stock\nBASE, and ``tool_allowlist: null`` resets to unrestricted. ``null``\non the boolean flags or ``applies_to_kinds`` is ignored (treated as\nabsent), so a client serializing unset optionals as null cannot\narchive a persona or flip levers by accident.\n\nArchive = ``{\"enabled\": false}``; default flip = ``{\"is_default\": true}``\non the successor (storage demotes the incumbent atomically). ``name``\nis immutable; existing workstreams are never affected by edits.", "properties": { "display_name": { "anyOf": [ @@ -11369,6 +11387,17 @@ }, "title": "Personas", "type": "array" + }, + "tool_inventory": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "Per-kind builtin tool names (plus the synthetic 'tool_search') for the visibility checklist \u2014 derived server-side so clients never hand-mirror the inventory", + "title": "Tool Inventory", + "type": "object" } }, "required": [ diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py index 88436aa0..457f11b0 100644 --- a/turnstone/api/console_schemas.py +++ b/turnstone/api/console_schemas.py @@ -147,6 +147,10 @@ class ConsoleCreateWsRequest(BaseModel): default="", description="Optional first message sent after creation" ) skill: str = Field(default="", description="Skill name (replaces default skills)") + persona: str = Field( + default="", + description="Persona slug; resolved and snapshotted at creation, empty = kind default", + ) resume_ws: str = Field( default="", description="Workstream ID to resume (loads previous conversation)" ) @@ -1085,10 +1089,18 @@ class CreatePersonaRequest(BaseModel): applies_to_kinds: list[str] = Field(default_factory=lambda: ["interactive"]) is_default: bool = False enabled: bool = True + org_id: str = Field(default="", description="Owning org (informational; capped at 64)") class UpdatePersonaRequest(BaseModel): - """PATCH body — only fields present in the JSON are applied. + """PATCH body — absent fields are left unchanged. + + Explicit ``null`` is meaningful only on the two resettable fields: + ``base_prompt: null`` clears the override back to the kind's stock + BASE, and ``tool_allowlist: null`` resets to unrestricted. ``null`` + on the boolean flags or ``applies_to_kinds`` is ignored (treated as + absent), so a client serializing unset optionals as null cannot + archive a persona or flip levers by accident. Archive = ``{"enabled": false}``; default flip = ``{"is_default": true}`` on the successor (storage demotes the incumbent atomically). ``name`` @@ -1108,6 +1120,14 @@ class UpdatePersonaRequest(BaseModel): class ListPersonasResponse(BaseModel): personas: list[PersonaInfo] + tool_inventory: dict[str, list[str]] = Field( + default_factory=dict, + description=( + "Per-kind builtin tool names (plus the synthetic 'tool_search') " + "for the visibility checklist — derived server-side so clients " + "never hand-mirror the inventory" + ), + ) class ModelReloadResponse(BaseModel): @@ -1243,6 +1263,10 @@ class CoordinatorCreateRequest(BaseModel): default=None, description="Optional skill name to apply to the coordinator session.", ) + persona: str = Field( + default="", + description="Persona slug; resolved and snapshotted at creation, empty = kind default", + ) initial_message: str = Field( default="", description="Optional first user message dispatched to the new coordinator session.", diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index fea74ab7..653fc4a4 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -413,12 +413,15 @@ class StorageBackend(Protocol): Returns rows of ``(ws_id, alias, title, name, created, updated, message_count, node_id, state, kind, model_alias, launch_skill, - child_count, context_tokens, context_window)`` ordered by updated - DESC. The trailing enrichment columns feed the saved-list DTO: - ``model_alias`` / ``launch_skill`` come from ``workstream_config``; - ``context_tokens`` is the most recent ``usage_events`` prompt size - and ``context_window`` the model's window (the caller divides them - for the occupancy ratio); ``child_count`` counts child workstreams. + child_count, context_tokens, context_window, project_id, user_id, + persona)`` ordered by updated DESC. The trailing enrichment columns + feed the saved-list DTO: ``model_alias`` / ``launch_skill`` come + from ``workstream_config``; ``context_tokens`` is the most recent + ``usage_events`` prompt size and ``context_window`` the model's + window (the caller divides them for the occupancy ratio); + ``child_count`` counts child workstreams. New columns MUST keep + appending at the tail — a full-arity unpack in session_routes + consumes this exact tuple. """ ... @@ -765,9 +768,10 @@ class StorageBackend(Protocol): Returns a list of SQLAlchemy ``Row`` objects. **Prefer dict access via ``row._mapping[]``**; positional indexing is brittle against future SELECT reorders and against new columns appearing in the - tail (the select currently ends with ``user_id, title, alias`` — - ``title``/``alias`` were appended after ``user_id`` so existing - positional fallbacks that index up to row[9] stay valid). + tail (the select currently ends with ``user_id, title, alias, + project_id, persona`` — appended in that order, so positional + fallbacks that index up to row[9] stay valid; new columns MUST + keep appending at the tail). """ ... @@ -2421,11 +2425,17 @@ class StorageBackend(Protocol): def create_persona(self, persona: dict[str, Any]) -> None: """Create a persona. Requires ``persona_id`` and ``name``; accepts the Python-typed dict shape above (JSON serialization is internal). - Raises ValueError on duplicate name or invalid ``applies_to_kinds``.""" + Raises ValueError on: missing ``persona_id``/``name``, duplicate + name, invalid ``applies_to_kinds``, oversized fields (caps live in + ``_utils.serialize_persona_fields``), or an ``is_default`` persona + that is multi-kind or disabled.""" ... def update_persona(self, persona_id: str, **fields: Any) -> bool: - """Update PERSONA_MUTABLE fields. Returns True if the persona exists. + """Update PERSONA_MUTABLE fields. Returns True only when the persona + exists AND at least one mutable field was supplied — a no-op call on + a real row returns False (check existence separately if you need to + distinguish "not found" from "nothing to update"). Invariants (raise ValueError): a default persona cannot be archived, cannot drop its ``is_default`` flag directly (flip the flag on the diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py index 18881e56..5b50b7e4 100644 --- a/turnstone/core/storage/_schema.py +++ b/turnstone/core/storage/_schema.py @@ -109,10 +109,11 @@ workstreams = sa.Table( # constraint (this schema family declares none — see migration 058). # Added in migration 062. sa.Column("project_id", sa.Text, nullable=True), - # persona: display name of the persona the workstream was created with - # (NULL = pre-persona workstream). Display/forensics only — the full - # persona snapshot lives in workstream_config; nothing reads this column - # to build a session. Added in migration 063. + # persona: SLUG of the persona the workstream was created with (NULL = + # pre-persona workstream) — personas.name, not display_name; clients + # resolve the display label. Display/forensics only — the full persona + # snapshot lives in workstream_config; nothing reads this column to + # build a session. Added in migration 063. sa.Column("persona", sa.Text, nullable=True), sa.Column("created", sa.Text, nullable=False), sa.Column("updated", sa.Text, nullable=False),