diff --git a/sdk/typescript/openapi-console.json b/sdk/typescript/openapi-console.json index bc9ff636..0a4f8e66 100644 --- a/sdk/typescript/openapi-console.json +++ b/sdk/typescript/openapi-console.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "turnstone Console API", - "version": "1.7.0a2", + "version": "1.7.0a6", "description": "Cluster-wide visibility and control across all turnstone nodes." }, "paths": { @@ -4213,6 +4213,166 @@ } } }, + "/v1/api/admin/personas": { + "get": { + "summary": "List all personas, archived included", + "operationId": "v1_api_admin_personas_get", + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPersonasResponse" + } + } + } + } + } + }, + "post": { + "summary": "Create a persona", + "operationId": "v1_api_admin_personas_post", + "tags": [ + "Admin" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonaRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonaInfo" + } + } + } + }, + "400": { + "description": "Error 400", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/api/admin/personas/{persona_id}": { + "get": { + "summary": "Get a single persona", + "operationId": "v1_api_admin_personas_{persona_id}_get", + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "persona_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonaInfo" + } + } + } + }, + "404": { + "description": "Error 404", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "patch": { + "summary": "Update a persona (edit levers, archive/unarchive, flip default)", + "operationId": "v1_api_admin_personas_{persona_id}_patch", + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "persona_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePersonaRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonaInfo" + } + } + } + }, + "400": { + "description": "Error 400", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Error 404", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/v1/api/admin/node-metadata": { "get": { "summary": "Get metadata for all nodes (bulk)", @@ -10896,6 +11056,327 @@ "title": "ListModelDefinitionsResponse", "type": "object" }, + "PersonaInfo": { + "description": "Full persona row \u2014 the authoring shape (contrast PersonaChoice, the\npicker's display-only projection on the server surface).", + "properties": { + "persona_id": { + "title": "Persona Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "display_name": { + "default": "", + "title": "Display Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "base_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "BASE-module override; null = the kind's stock base", + "title": "Base Prompt" + }, + "tool_allowlist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Tool visibility set: null = unrestricted, [] = no tools, [names] = exact set (include 'tool_search' to keep the set soft/expandable)", + "title": "Tool Allowlist" + }, + "mcp_enabled": { + "default": true, + "title": "Mcp Enabled", + "type": "boolean" + }, + "memory_enabled": { + "default": true, + "title": "Memory Enabled", + "type": "boolean" + }, + "applies_to_kinds": { + "items": { + "type": "string" + }, + "title": "Applies To Kinds", + "type": "array" + }, + "is_default": { + "default": false, + "title": "Is Default", + "type": "boolean" + }, + "enabled": { + "default": true, + "description": "false = archived", + "title": "Enabled", + "type": "boolean" + }, + "org_id": { + "default": "", + "title": "Org Id", + "type": "string" + }, + "created_by": { + "default": "", + "title": "Created By", + "type": "string" + }, + "created": { + "default": "", + "title": "Created", + "type": "string" + }, + "updated": { + "default": "", + "title": "Updated", + "type": "string" + } + }, + "required": [ + "persona_id", + "name" + ], + "title": "PersonaInfo", + "type": "object" + }, + "CreatePersonaRequest": { + "properties": { + "name": { + "description": "Immutable slug (lowercase: a-z, 0-9, '-', '_')", + "title": "Name", + "type": "string" + }, + "display_name": { + "default": "", + "title": "Display Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "base_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Prompt" + }, + "tool_allowlist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Allowlist" + }, + "mcp_enabled": { + "default": true, + "title": "Mcp Enabled", + "type": "boolean" + }, + "memory_enabled": { + "default": true, + "title": "Memory Enabled", + "type": "boolean" + }, + "applies_to_kinds": { + "items": { + "type": "string" + }, + "title": "Applies To Kinds", + "type": "array" + }, + "is_default": { + "default": false, + "title": "Is Default", + "type": "boolean" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "CreatePersonaRequest", + "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.", + "properties": { + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "base_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Prompt" + }, + "tool_allowlist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Allowlist" + }, + "mcp_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mcp Enabled" + }, + "memory_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Enabled" + }, + "applies_to_kinds": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Applies To Kinds" + }, + "is_default": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Default" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + } + }, + "title": "UpdatePersonaRequest", + "type": "object" + }, + "ListPersonasResponse": { + "properties": { + "personas": { + "items": { + "$ref": "#/components/schemas/PersonaInfo" + }, + "title": "Personas", + "type": "array" + } + }, + "required": [ + "personas" + ], + "title": "ListPersonasResponse", + "type": "object" + }, "ModelReloadResponse": { "properties": { "status": { diff --git a/sdk/typescript/openapi-server.json b/sdk/typescript/openapi-server.json index 766abebe..5d672184 100644 --- a/sdk/typescript/openapi-server.json +++ b/sdk/typescript/openapi-server.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "turnstone Server API", - "version": "1.7.0a2", + "version": "1.7.0a6", "description": "Single-node workstream management, chat interaction, and real-time streaming." }, "paths": { @@ -1443,6 +1443,27 @@ } } }, + "/v1/api/personas": { + "get": { + "summary": "List enabled personas for the workstream-creation picker", + "operationId": "v1_api_personas_get", + "tags": [ + "Personas" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPersonaChoicesResponse" + } + } + } + } + } + } + }, "/v1/api/models": { "get": { "summary": "List available model aliases", @@ -2425,6 +2446,12 @@ "title": "Skill", "type": "string" }, + "persona": { + "default": "", + "description": "Persona name (slug) to create the workstream with. Resolved and snapshotted at creation \u2014 later persona edits never affect this workstream. Empty selects the kind's default persona; on a database with no personas seeded the workstream is created with legacy (unrestricted) behavior.", + "title": "Persona", + "type": "string" + }, "notify_targets": { "anyOf": [ { @@ -3150,6 +3177,30 @@ "default": 0.0, "title": "Context Ratio", "type": "number" + }, + "project_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Project Id" + }, + "persona": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Persona" } }, "required": [ @@ -3738,6 +3789,65 @@ "title": "ListSkillSummaryResponse", "type": "object" }, + "PersonaChoice": { + "description": "Display fields for the creation picker \u2014 the persona's levers\n(prompt / tool set / toggles) deliberately stay server-side.", + "properties": { + "name": { + "description": "Persona slug, the value to pass as CreateWorkstreamRequest.persona", + "title": "Name", + "type": "string" + }, + "display_name": { + "default": "", + "description": "Human-readable name", + "title": "Display Name", + "type": "string" + }, + "description": { + "default": "", + "description": "What this persona is for", + "title": "Description", + "type": "string" + }, + "applies_to_kinds": { + "description": "Workstream kinds this persona can be attached to", + "items": { + "type": "string" + }, + "title": "Applies To Kinds", + "type": "array" + }, + "is_default": { + "default": false, + "description": "Whether an empty persona field resolves to this one", + "title": "Is Default", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "PersonaChoice", + "type": "object" + }, + "ListPersonaChoicesResponse": { + "properties": { + "personas": { + "items": { + "$ref": "#/components/schemas/PersonaChoice" + }, + "title": "Personas", + "type": "array" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + } + }, + "title": "ListPersonaChoicesResponse", + "type": "object" + }, "AvailableModelInfo": { "properties": { "alias": { diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index adfe4617..b2457381 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -130,6 +130,12 @@ export interface CreateWorkstreamRequest { auto_approve?: boolean; resume_ws?: string; skill?: string; + /** + * Persona name (slug) to create the workstream with. Resolved and + * snapshotted at creation — later persona edits never affect this + * workstream. Empty selects the kind's default persona. + */ + persona?: string; /** * Optional project to attach this workstream to. Drives the shared * `project` memory scope; coordinator children inherit the parent's project. @@ -256,6 +262,8 @@ export interface SavedWorkstreamInfo { child_count?: number; context_tokens?: number; context_ratio?: number; + /** Persona slug the workstream was created with (empty/absent = pre-persona). */ + persona?: string | null; } export interface ListSavedWorkstreamsResponse { @@ -524,6 +532,8 @@ export interface ConsoleCreateWsRequest { model?: string; initial_message?: string; skill?: string; + /** Persona slug — resolved and snapshotted at creation. */ + persona?: string; resume_ws?: string; } diff --git a/tests/test_cooperative_compaction.py b/tests/test_cooperative_compaction.py index ac1c5375..eba584cc 100644 --- a/tests/test_cooperative_compaction.py +++ b/tests/test_cooperative_compaction.py @@ -855,7 +855,6 @@ class TestChunkedCompaction: # A small but non-empty tool set so _tool_def_tokens() > 0 makes the # assertion meaningful. session._tool_search = None - session.creative_mode = False session._tools = [ { "type": "function", diff --git a/tests/test_persona_snapshot.py b/tests/test_persona_snapshot.py new file mode 100644 index 00000000..f33f1c58 --- /dev/null +++ b/tests/test_persona_snapshot.py @@ -0,0 +1,102 @@ +"""Tests for the persona snapshot codec (turnstone.core.personas). + +The stamp is the load-bearing seam of the feature: it must round-trip the +tri-state tool set byte-stably, treat a missing stamp as legacy, and treat a +partial or unparseable stamp as loud corruption — never as a silent fallback +to some default envelope. +""" + +from __future__ import annotations + +import pytest + +from turnstone.core.personas import ( + PERSONA_CONFIG_KEYS, + PersonaSnapshot, + snapshot_from_config, + snapshot_from_persona, +) + + +class TestSnapshotFromPersona: + def test_full_row(self) -> None: + snap = snapshot_from_persona( + { + "name": "scribe", + "base_prompt": "You are a scribe.", + "tool_allowlist": [], + "mcp_enabled": False, + "memory_enabled": False, + } + ) + assert snap.name == "scribe" + assert snap.prompt == "You are a scribe." + assert snap.tools == frozenset() + assert snap.mcp is False + assert snap.memory is False + + def test_null_levers_stay_open(self) -> None: + snap = snapshot_from_persona({"name": "engineer", "base_prompt": None}) + assert snap.prompt == "" + assert snap.tools is None + assert snap.mcp is True + assert snap.memory is True + + +class TestConfigRoundTrip: + @pytest.mark.parametrize( + "tools", + [None, frozenset(), frozenset({"read_file", "search", "memory"})], + ) + def test_tristate_roundtrip(self, tools: frozenset[str] | None) -> None: + snap = PersonaSnapshot(name="p", prompt="base", tools=tools, mcp=False, memory=True) + assert snapshot_from_config(snap.to_config()) == snap + + def test_to_config_is_byte_stable(self) -> None: + snap = PersonaSnapshot( + name="p", prompt="", tools=frozenset({"b", "a"}), mcp=True, memory=True + ) + cfg = snap.to_config() + assert cfg["persona_tools"] == '["a", "b"]' # sorted → stable across saves + assert set(cfg) == set(PERSONA_CONFIG_KEYS) + assert snapshot_from_config(cfg).to_config() == cfg + + +class TestConfigParsing: + def test_absent_is_legacy(self) -> None: + assert snapshot_from_config({}) is None + assert snapshot_from_config({"model": "x", "skill": "y"}) is None + + def test_partial_stamp_is_corrupt(self) -> None: + cfg = PersonaSnapshot("p", "", None, True, True).to_config() + del cfg["persona_tools"] + with pytest.raises(ValueError, match="missing keys"): + snapshot_from_config(cfg) + + def test_companions_without_name_are_corrupt(self) -> None: + with pytest.raises(ValueError, match="without 'persona'"): + snapshot_from_config({"persona_mcp": "1"}) + + def test_empty_name_is_corrupt(self) -> None: + cfg = PersonaSnapshot("p", "", None, True, True).to_config() + cfg["persona"] = "" + with pytest.raises(ValueError, match="empty persona name"): + snapshot_from_config(cfg) + + def test_bad_tools_json_is_corrupt(self) -> None: + cfg = PersonaSnapshot("p", "", None, True, True).to_config() + cfg["persona_tools"] = "not json" + with pytest.raises(ValueError, match="not JSON"): + snapshot_from_config(cfg) + + def test_wrong_tools_shape_is_corrupt(self) -> None: + cfg = PersonaSnapshot("p", "", None, True, True).to_config() + cfg["persona_tools"] = '{"read_file": true}' + with pytest.raises(ValueError, match="null or a list"): + snapshot_from_config(cfg) + + def test_bad_flag_is_corrupt(self) -> None: + cfg = PersonaSnapshot("p", "", None, True, True).to_config() + cfg["persona_memory"] = "True" + with pytest.raises(ValueError, match="persona_memory"): + snapshot_from_config(cfg) diff --git a/tests/test_server_live.py b/tests/test_server_live.py index dd554d5c..2622a0f1 100644 --- a/tests/test_server_live.py +++ b/tests/test_server_live.py @@ -521,24 +521,39 @@ class TestMultiTurn: class TestSessionConfig: """Test session construction and configuration with mocked responses.""" - def test_creative_mode_no_tools(self, tmp_db): - """In creative mode, create() is called WITHOUT tools kwarg.""" + def test_empty_toolset_persona_no_tools_on_wire(self, tmp_db): + """Guard: an empty-toolset persona (writer/scribe) sends ZERO tool + definitions on the wire — create() is called without a tools kwarg. + Replaces the removed /creative fork's equivalent assertion.""" + from turnstone.core.personas import PersonaSnapshot + client = _mock_client() client.chat.completions.create.return_value = make_mock_stream( content_tokens=["A haiku about code"], ) - session, ui = _make_session(client, "mock-model", tmp_db, max_tokens=256) + session, ui = _make_session( + client, + "mock-model", + tmp_db, + max_tokens=256, + persona_snapshot=PersonaSnapshot( + name="writer", + prompt="You are a creative writing partner.", + tools=frozenset(), + mcp=False, + memory=True, + ), + ) session._title_generated = True - session.creative_mode = True - # Re-init system messages so creative_mode takes effect - session._init_system_messages() session.send("Write a haiku about code.") # Verify create() was called without 'tools' in kwargs call_kwargs = client.chat.completions.create.call_args - assert "tools" not in call_kwargs.kwargs, "tools should not be passed in creative mode" + assert "tools" not in call_kwargs.kwargs, ( + "tools should not be passed under an empty-toolset persona" + ) # Should get content back without tool calls assert len(ui.full_content) > 0 diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py index 9d39cb36..7a3fe1df 100644 --- a/tests/test_session_manager.py +++ b/tests/test_session_manager.py @@ -196,6 +196,7 @@ class _Row: updated: str = "" node_id: str | None = None project_id: str | None = None + persona: str | None = None class FakeStorage: @@ -235,6 +236,7 @@ class FakeStorage: kind: WorkstreamKind | str = WorkstreamKind.INTERACTIVE, parent_ws_id: str | None = None, project_id: str | None = None, + persona: str | None = None, skill_id: str = "", skill_version: int = 0, state: str = "idle", @@ -254,6 +256,7 @@ class FakeStorage: updated=updated if updated is not None else self._now_iso(), node_id=node_id, project_id=project_id, + persona=persona if persona else None, ) def touch_workstream(self, ws_id: str) -> None: @@ -323,6 +326,7 @@ class FakeStorage: "kind": row.kind, "state": row.state, "parent_ws_id": row.parent_ws_id, + "persona": row.persona, } def list_workstreams( @@ -760,7 +764,7 @@ def test_open_threads_saved_model_alias_into_build_session() -> None: ``workstream_config`` (INSERT OR REPLACE) → the subsequent ``resume()`` restores what is now the default. Net effect: every persisted knob (model, temperature, reasoning_effort, max_tokens, - skill, creative_mode, instructions, …) silently resets on every + skill, the persona stamp, instructions, …) silently resets on every reopen and on every service restart. """ mgr, adapter, storage = _make_manager() diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 5bd99af8..4daca094 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -523,7 +523,15 @@ class TestInterruptedWorkstreamRepair: class TestWorkstreamConfig: def test_save_load_roundtrip(self, tmp_db): - config = {"temperature": "0.3", "reasoning_effort": "high", "creative_mode": "False"} + config = { + "temperature": "0.3", + "reasoning_effort": "high", + "persona": "scribe", + "persona_prompt": "You are a scribe.", + "persona_tools": "[]", + "persona_mcp": "0", + "persona_memory": "0", + } save_workstream_config("s1", config) loaded = load_workstream_config("s1") assert loaded == config @@ -566,7 +574,11 @@ class TestWorkstreamConfig: "reasoning_effort": "high", "max_tokens": "2048", "instructions": "be concise", - "creative_mode": "True", + "persona": "writer", + "persona_prompt": "You are a creative writing partner.", + "persona_tools": "[]", + "persona_mcp": "0", + "persona_memory": "1", }, ) @@ -581,13 +593,20 @@ class TestWorkstreamConfig: tool_timeout=30, ) assert session.temperature == 0.7 # default + assert session._persona_name == "" # unstamped constructor default result = session.resume("orig") assert result is True assert session.temperature == 0.3 assert session.reasoning_effort == "high" assert session.max_tokens == 2048 assert session.instructions == "be concise" - assert session.creative_mode is True + # Non-fork resume adopts the target's persona stamp so a later + # _save_config can't clobber it with this session's own stamp. + assert session._persona_name == "writer" + assert session._persona_prompt == "You are a creative writing partner." + assert session._persona_tools == frozenset() + assert session._persona_mcp is False + assert session._persona_memory is True def test_resume_keeps_defaults_when_alias_unresolvable(self, tmp_db): """When the saved alias is empty or no longer in the registry, @@ -639,8 +658,8 @@ class TestWorkstreamConfig: builds a ChatSession with the persisted ws_id; the legacy ``__init__`` unconditionally called ``_save_config()`` which is ``INSERT OR REPLACE`` per-key — silently resetting model_alias, - temperature, reasoning_effort, max_tokens, skill, creative_mode, - and instructions to the constructor defaults *before* + temperature, reasoning_effort, max_tokens, skill, the persona + stamp, and instructions to the constructor defaults *before* ``resume()`` got a chance to read them back. """ client = MagicMock() @@ -660,7 +679,11 @@ class TestWorkstreamConfig: "temperature": "0.2", "reasoning_effort": "high", "max_tokens": "8192", - "creative_mode": "True", + "persona": "scribe", + "persona_prompt": "You are a scribe.", + "persona_tools": "[]", + "persona_mcp": "0", + "persona_memory": "0", "instructions": "preserve me", }, ) @@ -683,7 +706,9 @@ class TestWorkstreamConfig: assert loaded["temperature"] == "0.2" assert loaded["reasoning_effort"] == "high" assert loaded["max_tokens"] == "8192" - assert loaded["creative_mode"] == "True" + assert loaded["persona"] == "scribe" + assert loaded["persona_tools"] == "[]" + assert loaded["persona_mcp"] == "0" assert loaded["instructions"] == "preserve me" def test_init_writes_config_on_fresh_create(self, tmp_db): diff --git a/tests/test_skills_tool.py b/tests/test_skills_tool.py index f4d78d07..379de8a5 100644 --- a/tests/test_skills_tool.py +++ b/tests/test_skills_tool.py @@ -1341,7 +1341,6 @@ class TestSkillCatalogDisclosure: session.context_window = 128000 session.messages = [] session._config = {} - session.creative_mode = False session.instructions = "" session.system_messages = [] session._agent_system_messages = [] @@ -1365,6 +1364,13 @@ class TestSkillCatalogDisclosure: session._project_id = "" session._project_writable = False session._kind = "interactive" + # Persona snapshot attrs (set by __init__, bypassed here) — legacy + # defaults: no override, unrestricted tools, MCP + memory on. + session._persona_name = "" + session._persona_prompt = "" + session._persona_tools = None + session._persona_mcp = True + session._persona_memory = True session._memory_config = MagicMock() session._memory_config.fetch_limit = 0 diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py index 741d99c7..88436aa0 100644 --- a/turnstone/api/console_schemas.py +++ b/turnstone/api/console_schemas.py @@ -1045,6 +1045,71 @@ class ListModelDefinitionsResponse(BaseModel): models: list[ModelDefinitionInfo] +class PersonaInfo(BaseModel): + """Full persona row — the authoring shape (contrast PersonaChoice, the + picker's display-only projection on the server surface).""" + + persona_id: str + name: str + display_name: str = "" + description: str = "" + base_prompt: str | None = Field( + default=None, description="BASE-module override; null = the kind's stock base" + ) + tool_allowlist: list[str] | None = Field( + default=None, + description=( + "Tool visibility set: null = unrestricted, [] = no tools, [names] = " + "exact set (include 'tool_search' to keep the set soft/expandable)" + ), + ) + mcp_enabled: bool = True + memory_enabled: bool = True + applies_to_kinds: list[str] = Field(default_factory=lambda: ["interactive"]) + is_default: bool = False + enabled: bool = Field(default=True, description="false = archived") + org_id: str = "" + created_by: str = "" + created: str = "" + updated: str = "" + + +class CreatePersonaRequest(BaseModel): + name: str = Field(description="Immutable slug (lowercase: a-z, 0-9, '-', '_')") + display_name: str = "" + description: str = "" + base_prompt: str | None = None + tool_allowlist: list[str] | None = None + mcp_enabled: bool = True + memory_enabled: bool = True + applies_to_kinds: list[str] = Field(default_factory=lambda: ["interactive"]) + is_default: bool = False + enabled: bool = True + + +class UpdatePersonaRequest(BaseModel): + """PATCH body — only fields present in the JSON are applied. + + Archive = ``{"enabled": false}``; default flip = ``{"is_default": true}`` + on the successor (storage demotes the incumbent atomically). ``name`` + is immutable; existing workstreams are never affected by edits. + """ + + display_name: str | None = None + description: str | None = None + base_prompt: str | None = None + tool_allowlist: list[str] | None = None + mcp_enabled: bool | None = None + memory_enabled: bool | None = None + applies_to_kinds: list[str] | None = None + is_default: bool | None = None + enabled: bool | None = None + + +class ListPersonasResponse(BaseModel): + personas: list[PersonaInfo] + + class ModelReloadResponse(BaseModel): status: str = "ok" results: dict[str, Any] = Field(default_factory=dict) diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index 8e7a817d..f66de2ee 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -42,6 +42,7 @@ from turnstone.api.console_schemas import ( CreateChannelUserRequest, CreateMcpServerRequest, CreateModelDefinitionRequest, + CreatePersonaRequest, CreateRoleRequest, CreateSkillRequest, CreateSkillResourceRequest, @@ -59,6 +60,7 @@ from turnstone.api.console_schemas import ( ListModelDefinitionsResponse, ListOrgsResponse, ListOutputAssessmentsResponse, + ListPersonasResponse, ListRolesResponse, ListSettingSchemaResponse, ListSettingsResponse, @@ -79,6 +81,7 @@ from turnstone.api.console_schemas import ( OutputAssessmentInfo, ParseSkillRequest, ParseSkillResponse, + PersonaInfo, RegistryInstallRequest, RegistrySearchResponse, RoleEffectiveResponse, @@ -99,6 +102,7 @@ from turnstone.api.console_schemas import ( UpdateMcpServerRequest, UpdateModelDefinitionRequest, UpdateOrgRequest, + UpdatePersonaRequest, UpdateRoleRequest, UpdateSettingRequest, UpdateSkillRequest, @@ -1050,6 +1054,40 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ error_codes=[404], tags=["Admin"], ), + # --- Admin: Personas (no DELETE — archive via PATCH enabled=false) --- + EndpointSpec( + "/v1/api/admin/personas", + "GET", + "List all personas, archived included", + response_model=ListPersonasResponse, + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/personas", + "POST", + "Create a persona", + request_model=CreatePersonaRequest, + response_model=PersonaInfo, + error_codes=[400], + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/personas/{persona_id}", + "GET", + "Get a single persona", + response_model=PersonaInfo, + error_codes=[404], + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/personas/{persona_id}", + "PATCH", + "Update a persona (edit levers, archive/unarchive, flip default)", + request_model=UpdatePersonaRequest, + response_model=PersonaInfo, + error_codes=[400, 404], + tags=["Admin"], + ), # --- Admin: Node metadata --- EndpointSpec( "/v1/api/admin/node-metadata", @@ -1648,6 +1686,10 @@ _ALL_MODELS: list[type[BaseModel]] = [ CreateModelDefinitionRequest, UpdateModelDefinitionRequest, ListModelDefinitionsResponse, + PersonaInfo, + CreatePersonaRequest, + UpdatePersonaRequest, + ListPersonasResponse, ModelReloadResponse, DetectModelRequest, DetectModelResponse, diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index d5fc9b5f..3b3c9009 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -143,6 +143,16 @@ class CreateWorkstreamRequest(BaseModel): description="Workstream ID to resume atomically during creation (empty = fresh start)", ) skill: str = Field(default="", description="Skill name (replaces default skills)") + persona: str = Field( + default="", + description=( + "Persona name (slug) to create the workstream with. Resolved and " + "snapshotted at creation — later persona edits never affect this " + "workstream. Empty selects the kind's default persona; on a " + "database with no personas seeded the workstream is created " + "with legacy (unrestricted) behavior." + ), + ) notify_targets: str | list[dict[str, str]] = Field( default="[]", description=( @@ -442,6 +452,7 @@ class SavedWorkstreamInfo(BaseModel): context_tokens: int = 0 context_ratio: float = 0.0 project_id: str | None = None + persona: str | None = None class ListSavedWorkstreamsResponse(BaseModel): @@ -651,6 +662,27 @@ class ListSkillSummaryResponse(BaseModel): skills: list[SkillSummary] +class PersonaChoice(BaseModel): + """Display fields for the creation picker — the persona's levers + (prompt / tool set / toggles) deliberately stay server-side.""" + + name: str = Field(description="Persona slug, the value to pass as CreateWorkstreamRequest.persona") + display_name: str = Field(default="", description="Human-readable name") + description: str = Field(default="", description="What this persona is for") + applies_to_kinds: list[str] = Field( + default_factory=list, + description="Workstream kinds this persona can be attached to", + ) + is_default: bool = Field( + default=False, description="Whether an empty persona field resolves to this one" + ) + + +class ListPersonaChoicesResponse(BaseModel): + personas: list[PersonaChoice] = Field(default_factory=list) + total: int = 0 + + class AvailableModelInfo(BaseModel): alias: str model: str diff --git a/turnstone/api/server_spec.py b/turnstone/api/server_spec.py index 0b8890b5..9d507aa8 100644 --- a/turnstone/api/server_spec.py +++ b/turnstone/api/server_spec.py @@ -32,10 +32,12 @@ from turnstone.api.server_schemas import ( ListAttachmentsResponse, ListAvailableModelsResponse, ListMemoriesResponse, + ListPersonaChoicesResponse, ListSavedWorkstreamsResponse, ListSkillSummaryResponse, ListWorkstreamsResponse, MemoryInfo, + PersonaChoice, RewindRequest, SaveMemoryRequest, SearchMemoriesRequest, @@ -343,6 +345,14 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [ response_model=ListSkillSummaryResponse, tags=["Skills"], ), + # --- Personas --- + EndpointSpec( + "/v1/api/personas", + "GET", + "List enabled personas for the workstream-creation picker", + response_model=ListPersonaChoicesResponse, + tags=["Personas"], + ), # --- Models --- EndpointSpec( "/v1/api/models", @@ -517,6 +527,8 @@ _ALL_MODELS: list[type[BaseModel]] = [ SearchMemoriesRequest, SkillSummary, ListSkillSummaryResponse, + PersonaChoice, + ListPersonaChoicesResponse, AvailableModelInfo, ListAvailableModelsResponse, ] diff --git a/turnstone/cli.py b/turnstone/cli.py index b6eb2e1a..4b6e76e9 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -52,6 +52,8 @@ _VERDICT_COLORS: dict[str, str] = { if TYPE_CHECKING: from collections.abc import Callable + from turnstone.core.personas import PersonaSnapshot + # ─── Readline ───────────────────────────────────────────────────────────── SLASH_COMMANDS = [ @@ -67,7 +69,6 @@ SLASH_COMMANDS = [ "/raw", "/reason", "/compact", - "/creative", "/debug", "/mcp", "/retry", @@ -885,6 +886,14 @@ def main() -> None: default=None, help="Skill name (replaces default skills)", ) + parser.add_argument( + "--persona", + default=None, + help=( + "Persona name for this session (resolved and snapshotted at start; " + "default: the interactive default persona)" + ), + ) parser.add_argument( "--temperature", type=float, @@ -1139,8 +1148,15 @@ def main() -> None: client_type: str = "", kind: WorkstreamKind = WorkstreamKind.INTERACTIVE, parent_ws_id: str | None = None, + # ``project_id`` is accepted (and dropped) because the shared + # InteractiveAdapter passes it unconditionally — without the + # parameter every CLI create/rehydrate TypeErrors. The CLI has + # no project surface, so the value is discarded. + project_id: str = "", + persona_snapshot: PersonaSnapshot | None = None, ) -> ChatSession: assert ui is not None, "session_factory requires a non-None UI" + del project_id r_client, r_model, r_cfg = registry.resolve(model_alias) return ChatSession( client=r_client, @@ -1167,6 +1183,7 @@ def main() -> None: judge_config=judge_config, kind=kind, parent_ws_id=parent_ws_id, + persona_snapshot=persona_snapshot, ) # Create session manager and initial workstream. The InteractiveAdapter @@ -1188,25 +1205,71 @@ def main() -> None: max_active=50, ) cli_adapter.attach(manager) - ws = manager.create(user_id="") + + # Resolve the persona stamp BEFORE constructing the session — the four + # levers apply inside ``ChatSession.__init__``, so resolution can't wait. + # ``--resume`` adopts the TARGET workstream's stamped persona (the + # resumed session must run from its stamp, not tonight's default); + # otherwise ``--persona`` (or the interactive default persona) resolves + # against the shelf. A database with no personas seeded yields an + # unstamped legacy session — byte-identical behavior. + from turnstone.core.personas import snapshot_from_config, snapshot_from_persona + + cli_storage = _get_storage() + resume_target: str | None = None + if args.resume: + from turnstone.core.memory import resolve_workstream + + resume_target = resolve_workstream(args.resume) + if not resume_target: + print(red(f"Workstream not found: {args.resume}")) + sys.exit(1) + + persona_kwargs: dict[str, Any] = {} + if resume_target and cli_storage is not None: + if args.persona: + print(yellow("--persona is ignored with --resume (the stamped persona applies)")) + # A corrupt stamp raises here — fail loudly at startup rather than + # silently running the workstream under a default envelope. + snap = snapshot_from_config(cli_storage.load_workstream_config(resume_target) or {}) + if snap is not None: + persona_kwargs = {"persona": snap.name, "persona_snapshot": snap} + elif args.persona: + row = cli_storage.get_persona_by_name(args.persona) if cli_storage else None + if not row or not row.get("enabled", False): + print(red(f"Persona not found or disabled: {args.persona}")) + sys.exit(1) + if "interactive" not in (row.get("applies_to_kinds") or []): + print(red(f"Persona '{args.persona}' does not apply to interactive sessions")) + sys.exit(1) + persona_kwargs = { + "persona": row["name"], + "persona_snapshot": snapshot_from_persona(row), + } + elif cli_storage is not None: + try: + default_row = cli_storage.get_default_persona("interactive") + except Exception: + default_row = None + if default_row: + persona_kwargs = { + "persona": default_row["name"], + "persona_snapshot": snapshot_from_persona(default_row), + } + + ws = manager.create(user_id="", **persona_kwargs) if args.skip_permissions and isinstance(ws.ui, TerminalUI): ws.ui.auto_approve = True # Handle --resume - if args.resume: - from turnstone.core.memory import resolve_workstream - - target_id = resolve_workstream(args.resume) - if not target_id: - print(red(f"Workstream not found: {args.resume}")) - sys.exit(1) + if resume_target: if ws.session is None: print(red("No session available.")) sys.exit(1) - if not ws.session.resume(target_id): + if not ws.session.resume(resume_target): print(red(f"Workstream '{args.resume}' has no messages.")) sys.exit(1) - print(f"Resumed workstream {bold(target_id)} ({len(ws.session.messages)} messages)") + print(f"Resumed workstream {bold(resume_target)} ({len(ws.session.messages)} messages)") # Background attention notification — write to stderr while user types def _bg_attention_notify(ws_id: str, state: WorkstreamState) -> None: diff --git a/turnstone/console/coordinator_client.py b/turnstone/console/coordinator_client.py index 68b2392c..9ecf3b13 100644 --- a/turnstone/console/coordinator_client.py +++ b/turnstone/console/coordinator_client.py @@ -764,6 +764,7 @@ class CoordinatorClient: model: str = "", target_node: str = "", project: str = "", + persona: str = "", ) -> dict[str, Any]: """Create a child workstream via the routing proxy.""" body: dict[str, Any] = { @@ -782,6 +783,11 @@ class CoordinatorClient: body["target_node"] = target_node if project: body["project_id"] = project + if persona: + # Re-resolved and stamped by the receiving node's create + # handler at child-creation time. Omitted = the interactive + # kind default — never the parent's persona. + body["persona"] = persona return self._post("spawn", body) def send(self, ws_id: str, message: str) -> dict[str, Any]: diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 66defeb9..c73143d2 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -2025,6 +2025,7 @@ async def create_workstream(request: Request) -> JSONResponse: raw_judge_model = body.get("judge_model", "") raw_initial_message = body.get("initial_message", "") raw_skill = body.get("skill", "") + raw_persona = body.get("persona", "") raw_resume_ws = body.get("resume_ws", "") raw_project_id = body.get("project_id", "") if not isinstance(raw_node_id, str): @@ -2039,6 +2040,8 @@ async def create_workstream(request: Request) -> JSONResponse: raw_initial_message = "" if raw_initial_message is None else None if not isinstance(raw_skill, str): raw_skill = "" if raw_skill is None else None + if not isinstance(raw_persona, str): + raw_persona = "" if raw_persona is None else None if not isinstance(raw_resume_ws, str): raw_resume_ws = "" if raw_resume_ws is None else None if not isinstance(raw_project_id, str): @@ -2050,12 +2053,13 @@ async def create_workstream(request: Request) -> JSONResponse: or raw_judge_model is None or raw_initial_message is None or raw_skill is None + or raw_persona is None or raw_resume_ws is None or raw_project_id is None ): return JSONResponse( { - "error": "node_id, name, model, judge_model, initial_message, skill, resume_ws, and project_id must be strings" + "error": "node_id, name, model, judge_model, initial_message, skill, persona, resume_ws, and project_id must be strings" }, status_code=400, ) @@ -2065,6 +2069,7 @@ async def create_workstream(request: Request) -> JSONResponse: judge_model = raw_judge_model[:128] initial_message = raw_initial_message[:4096] skill = raw_skill[:256] + persona = raw_persona[:64] resume_ws = raw_resume_ws[:64] project_id = raw_project_id[:64] @@ -2098,6 +2103,7 @@ async def create_workstream(request: Request) -> JSONResponse: "judge_model": judge_model, "initial_message": initial_message, "skill": skill, + "persona": persona, "resume_ws": resume_ws, "user_id": uid, "project_id": project_id, @@ -11802,6 +11808,203 @@ async def admin_delete_prompt_policy(request: Request) -> JSONResponse: return JSONResponse({"status": "ok", "policy_id": policy_id}) +# --------------------------------------------------------------------------- +# Admin: Personas (workstream capability/prompt templates) +# --------------------------------------------------------------------------- +# Authoring surface for the personas shelf. Deliberately no DELETE handler: +# personas are archived (``enabled=false`` via PATCH), never hard-deleted, so +# a workstream's stamped provenance stays explicable forever. + +_PERSONA_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$") +_PERSONA_PROMPT_CAP = 32768 # same cap as prompt-policy content + + +def _parse_persona_body(body: dict[str, Any]) -> tuple[dict[str, Any] | None, JSONResponse | None]: + """Normalize the shared create/update persona fields. + + Returns ``(fields, None)`` on success or ``(None, 400-response)``. + Only keys present in the body land in ``fields`` so PATCH stays + partial; storage enforces the default-persona invariants. + """ + fields: dict[str, Any] = {} + if "display_name" in body: + fields["display_name"] = str(body.get("display_name") or "").strip()[:128] + if "description" in body: + fields["description"] = str(body.get("description") or "").strip()[:1024] + if "base_prompt" in body: + prompt = body.get("base_prompt") + if prompt is not None and not isinstance(prompt, str): + return None, JSONResponse( + {"error": "base_prompt must be a string or null"}, status_code=400 + ) + fields["base_prompt"] = prompt[:_PERSONA_PROMPT_CAP] if prompt else prompt + if "tool_allowlist" in body: + tools = body.get("tool_allowlist") + if tools is not None and ( + not isinstance(tools, list) or not all(isinstance(t, str) for t in tools) + ): + return None, JSONResponse( + {"error": "tool_allowlist must be a list of tool names or null"}, + status_code=400, + ) + fields["tool_allowlist"] = tools + if "applies_to_kinds" in body: + kinds = body.get("applies_to_kinds") + if not isinstance(kinds, list) or not all(isinstance(k, str) for k in kinds): + return None, JSONResponse( + {"error": "applies_to_kinds must be a list of kinds"}, status_code=400 + ) + fields["applies_to_kinds"] = kinds + for flag in ("mcp_enabled", "memory_enabled", "is_default", "enabled"): + if flag in body: + fields[flag] = bool(body.get(flag)) + return fields, None + + +async def admin_list_personas(request: Request) -> JSONResponse: + """GET /v1/api/admin/personas — all personas, archived included.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "persona.read") + if err: + return err + + return JSONResponse({"personas": storage.list_personas(include_disabled=True)}) + + +async def admin_create_persona(request: Request) -> JSONResponse: + """POST /v1/api/admin/personas — create a persona.""" + import uuid + + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "persona.create") + if err: + return err + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + name = str(body.get("name", "")).strip()[:64] + if not name or not _PERSONA_NAME_RE.match(name): + return JSONResponse( + {"error": "name is required (lowercase slug: a-z, 0-9, '-', '_')"}, + status_code=400, + ) + fields, ferr = _parse_persona_body(body) + if ferr is not None: + return ferr + assert fields is not None + + persona_id = uuid.uuid4().hex + audit_uid, ip = _audit_context(request) + fields.update( + { + "persona_id": persona_id, + "name": name, + "org_id": str(body.get("org_id", "")).strip(), + "created_by": audit_uid, + } + ) + try: + storage.create_persona(fields) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + + record_audit( + storage, + audit_uid, + "persona.create", + "persona", + persona_id, + {"name": name}, + ip, + ) + + return JSONResponse(storage.get_persona(persona_id) or {}) + + +async def admin_get_persona(request: Request) -> JSONResponse: + """GET /v1/api/admin/personas/{persona_id}.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "persona.read") + if err: + return err + + persona = storage.get_persona(request.path_params["persona_id"]) + if persona is None: + return JSONResponse({"error": "Persona not found"}, status_code=404) + return JSONResponse(persona) + + +async def admin_update_persona(request: Request) -> JSONResponse: + """PATCH /v1/api/admin/personas/{persona_id} — edit / archive / default flip. + + Editing a persona NEVER touches existing workstreams: they run on the + snapshot stamped at creation. + """ + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "persona.write") + if err: + return err + + persona_id = request.path_params["persona_id"] + existing = storage.get_persona(persona_id) + if existing is None: + return JSONResponse({"error": "Persona not found"}, status_code=404) + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + fields, ferr = _parse_persona_body(body) + if ferr is not None: + return ferr + assert fields is not None + if not fields: + return JSONResponse({"error": "no editable fields in body"}, status_code=400) + + try: + storage.update_persona(persona_id, **fields) + except ValueError as exc: + # Storage-enforced invariants: default not archivable / must stay + # single-kind / can't unset is_default directly / kinds validation. + return JSONResponse({"error": str(exc)}, status_code=400) + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "persona.update", + "persona", + persona_id, + {"name": existing.get("name", "")}, + ip, + ) + + return JSONResponse(storage.get_persona(persona_id) or {}) + + # --------------------------------------------------------------------------- # Admin: Judge (heuristic rules, output guard patterns, settings) # --------------------------------------------------------------------------- @@ -13199,6 +13402,7 @@ def create_app( create_project, delete_project_endpoint, get_project_endpoint, + list_personas_endpoint, list_project_members_endpoint, list_projects, project_resources_endpoint, @@ -13636,6 +13840,9 @@ def create_app( remove_project_member_endpoint, methods=["DELETE"], ), + # Personas picker feed (server handler served verbatim — + # same borrow as the projects block above). + Route("/api/personas", list_personas_endpoint), # System: Settings Route("/api/admin/settings", admin_list_settings), Route("/api/admin/settings/schema", admin_settings_schema), @@ -13764,6 +13971,19 @@ def create_app( admin_delete_prompt_policy, methods=["DELETE"], ), + # Governance: Personas (no DELETE — archive via PATCH) + Route("/api/admin/personas", admin_list_personas), + Route( + "/api/admin/personas", + admin_create_persona, + methods=["POST"], + ), + Route("/api/admin/personas/{persona_id}", admin_get_persona), + Route( + "/api/admin/personas/{persona_id}", + admin_update_persona, + methods=["PATCH"], + ), # Governance: Judge Rules Route("/api/admin/judge/settings", admin_list_judge_settings), Route( diff --git a/turnstone/console/session_factory.py b/turnstone/console/session_factory.py index b915e7e7..b5dad698 100644 --- a/turnstone/console/session_factory.py +++ b/turnstone/console/session_factory.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from turnstone.console.coordinator_client import CoordinatorClient from turnstone.core.config_store import ConfigStore from turnstone.core.model_registry import ModelRegistry + from turnstone.core.personas import PersonaSnapshot from turnstone.core.session import SessionUI log = get_logger(__name__) @@ -96,6 +97,7 @@ def build_console_session_factory( parent_ws_id: str | None = None, project_id: str = "", judge_model: str | None = None, + persona_snapshot: PersonaSnapshot | None = None, ) -> ChatSession: assert ui is not None, "console session_factory requires a non-None UI" if kind != WorkstreamKind.COORDINATOR: @@ -226,6 +228,7 @@ def build_console_session_factory( parent_ws_id=parent_ws_id, project_id=project_id, coord_client=coord_client, + persona_snapshot=persona_snapshot, ) return factory diff --git a/turnstone/core/metacognition.py b/turnstone/core/metacognition.py index 3be9e7be..1f675cf1 100644 --- a/turnstone/core/metacognition.py +++ b/turnstone/core/metacognition.py @@ -156,6 +156,16 @@ _NUDGE_MAP: dict[str, str] = { "participant_joined": "", } +# Nudge types whose copy directs the model at the memory tool ("save that +# as a feedback memory", "use memory(action='search')"). A memory-off +# persona suppresses these — advertising a tool the persona hides produces +# the same "I don't have access" apologies the memory-advisory gating +# fixed — while behavioural nudges (repeat, compaction_pending, +# idle_children, watch_triggered) keep firing. +MEMORY_NUDGE_TYPES: frozenset[str] = frozenset( + {"correction", "denial", "resume", "completion", "start", "tool_error"} +) + # Display cap for the ``idle_children`` body — list at most this many # children inline, append "...and N more" overflow line beyond that. diff --git a/turnstone/core/personas.py b/turnstone/core/personas.py new file mode 100644 index 00000000..b8d209fd --- /dev/null +++ b/turnstone/core/personas.py @@ -0,0 +1,133 @@ +"""Persona snapshot — the per-workstream stamp of a persona's four levers. + +A persona (see migration 063 / ``storage.list_personas``) is resolved ONCE at +workstream creation and stamped into ``workstream_config`` as five keys. From +then on the session reads only the stamp: editing or archiving the persona +never changes an existing workstream, and a workstream outlives its persona. + +The five keys (all-or-none — a partial stamp is corruption, not a fallback): + +- ``persona`` — the persona's slug (display + forensics) +- ``persona_prompt`` — BASE-module override; ``""`` = the kind's stock base +- ``persona_tools`` — JSON tri-state: ``null`` = unrestricted, ``[]`` = + hard empty, ``[names]`` = exact visibility set (``tool_search`` membership + decides soft vs hard) +- ``persona_mcp`` — ``"1"``/``"0"``: whether the workstream talks to MCP + at all (session-wide, including task-agent merges) +- ``persona_memory`` — ``"1"``/``"0"``: whether the persona's own hands get + memory (recall injection, nudges, the memory tool); task agents keep theirs + +Workstreams with none of the keys predate personas (or were created against a +pre-seed database) and keep legacy behaviour — byte-identical to the +``engineer``/``orchestrator`` defaults. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + +PERSONA_CONFIG_KEYS = ( + "persona", + "persona_prompt", + "persona_tools", + "persona_mcp", + "persona_memory", +) + + +@dataclass(frozen=True) +class PersonaSnapshot: + """Immutable, self-contained persona stamp held by a live session.""" + + name: str + prompt: str # "" = use the kind's stock BASE module + tools: frozenset[str] | None # None = unrestricted; frozenset() = hard empty + mcp: bool + memory: bool + + def to_config(self) -> dict[str, str]: + """Serialize to the five ``workstream_config`` values. + + The tool set is written sorted so save/load round-trips are + byte-stable (the semantics are set-based; order carries nothing). + """ + return { + "persona": self.name, + "persona_prompt": self.prompt, + "persona_tools": ( + json.dumps(sorted(self.tools)) if self.tools is not None else "null" + ), + "persona_mcp": "1" if self.mcp else "0", + "persona_memory": "1" if self.memory else "0", + } + + +def snapshot_from_persona(persona: Mapping[str, Any]) -> PersonaSnapshot: + """Build the stamp from a storage persona row — the resolve-once moment.""" + tools = persona.get("tool_allowlist") + return PersonaSnapshot( + name=str(persona["name"]), + prompt=persona.get("base_prompt") or "", + tools=None if tools is None else frozenset(tools), + mcp=bool(persona.get("mcp_enabled", True)), + memory=bool(persona.get("memory_enabled", True)), + ) + + +def snapshot_from_config(config: Mapping[str, str]) -> PersonaSnapshot | None: + """Parse the stamp back out of persisted ``workstream_config`` values. + + Returns ``None`` when no persona was stamped (legacy pre-063 workstream). + Raises ``ValueError`` when the stamp is partial or unparseable — the + session must fail loudly rather than silently fall back to a default + envelope the operator never chose for this workstream. + """ + if "persona" not in config: + stray = [k for k in PERSONA_CONFIG_KEYS if k in config] + if stray: + raise ValueError( + f"corrupt persona snapshot: companion keys {stray} present without 'persona'" + ) + return None + missing = [k for k in PERSONA_CONFIG_KEYS if k not in config] + if missing: + raise ValueError(f"corrupt persona snapshot: missing keys {missing}") + name = config["persona"] + if not name: + raise ValueError("corrupt persona snapshot: empty persona name") + raw_tools = config["persona_tools"] + try: + tools_val = json.loads(raw_tools) + except (json.JSONDecodeError, TypeError) as exc: + raise ValueError( + f"corrupt persona snapshot: persona_tools is not JSON: {raw_tools!r}" + ) from exc + tools: frozenset[str] | None + if tools_val is None: + tools = None + elif isinstance(tools_val, list) and all(isinstance(t, str) for t in tools_val): + tools = frozenset(tools_val) + else: + raise ValueError( + f"corrupt persona snapshot: persona_tools must be null or a list " + f"of names, got {raw_tools!r}" + ) + flags = {} + for key in ("persona_mcp", "persona_memory"): + if config[key] not in ("0", "1"): + raise ValueError( + f"corrupt persona snapshot: {key} must be '0' or '1', got {config[key]!r}" + ) + flags[key] = config[key] == "1" + return PersonaSnapshot( + name=name, + prompt=config["persona_prompt"], + tools=tools, + mcp=flags["persona_mcp"], + memory=flags["persona_memory"], + ) diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 5569ac6d..6b25c7b8 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -104,6 +104,7 @@ from turnstone.core.memory_relevance import ( score_memories, ) from turnstone.core.metacognition import ( + MEMORY_NUDGE_TYPES, NUDGE_COMPACTION_RESUME, RepeatDetector, detect_completion, @@ -113,6 +114,7 @@ from turnstone.core.metacognition import ( should_nudge, ) from turnstone.core.nudge_queue import TOOL_DRAIN, USER_DRAIN, NudgeQueue +from turnstone.core.personas import PersonaSnapshot, snapshot_from_config from turnstone.core.providers import create_provider from turnstone.core.ratelimit import TokenBucket from turnstone.core.safety import is_command_blocked, sanitize_command @@ -1146,6 +1148,7 @@ class ChatSession: parent_ws_id: str | None = None, coord_client: Any = None, project_id: str = "", + persona_snapshot: PersonaSnapshot | None = None, ): if kind == WorkstreamKind.COORDINATOR and not user_id: # Coordinators carry real authority — they mint child-spawn @@ -1327,6 +1330,20 @@ class ChatSession: self._project_id = project_id self._project_writable = acc.can_write self._project_name = acc.name + # Persona snapshot — the four levers, resolved ONCE at workstream + # creation and immutable for the session's lifetime. Everything + # below (tool merge, MCP gate, composition, memory) reads these + # attrs, never the personas table: editing or archiving a persona + # must not change an existing workstream. ``None`` = legacy + # pre-persona workstream — all levers at their open positions, + # byte-identical to today. + self._persona_name: str = persona_snapshot.name if persona_snapshot else "" + self._persona_prompt: str = persona_snapshot.prompt if persona_snapshot else "" + self._persona_tools: frozenset[str] | None = ( + persona_snapshot.tools if persona_snapshot else None + ) + self._persona_mcp: bool = persona_snapshot.mcp if persona_snapshot else True + self._persona_memory: bool = persona_snapshot.memory if persona_snapshot else True self._title_generated = False self._read_files: set[str] = set() # The canonical in-memory trajectory. Wire prep (fold/repair) + the @@ -1346,7 +1363,6 @@ class ChatSession: self._applied_skill_content: str = "" # inline prompt from applied skill self._assistant_pending_tokens = 0 self._calibrated_msg_count = 0 # len(messages) at last _update_token_table - self.creative_mode = False self._notify_count = 0 # Watch support: server-level runner injected via set_watch_runner() self._watch_runner: Any = None # WatchRunner | None @@ -1429,8 +1445,14 @@ class ChatSession: # with many tools is not throttled. Reset alongside the judge # instance at the model-swap paths. self._output_guard_judge_rl = TokenBucket(rate=1.0, burst=60) - # MCP tool integration: merge external tools with built-in - self._mcp_client = mcp_client + # MCP tool integration: merge external tools with built-in. + # Persona MCP gate (lever 3) is SESSION-WIDE infrastructure intent — + # "this workstream does not talk to MCP" — so an MCP-off persona + # drops the client reference outright: no merge into _tools OR + # _task_tools, no listeners, no resource/prompt catalogs, and the + # refresh callbacks stay inert (they all guard on _mcp_client). + # Task agents keep their native tools; only the MCP surface closes. + self._mcp_client = mcp_client if self._persona_mcp else None self._mcp_refresh_cb: Any = None # Callable | None (avoid import) self._mcp_resource_cb: Any = None self._mcp_prompt_cb: Any = None @@ -1448,34 +1470,36 @@ class ChatSession: if kind == WorkstreamKind.COORDINATOR: self._tools = list(COORDINATOR_TOOLS) self._task_tools = [] - elif mcp_client: - mcp_tools = mcp_client.get_tools(user_id=self._mcp_user_id) + elif self._mcp_client: + # ``self._mcp_client`` (not the raw kwarg) so an MCP-off persona + # falls through to the no-MCP branch below. + mcp_tools = self._mcp_client.get_tools(user_id=self._mcp_user_id) self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools) self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools) # Register for tool-change notifications from MCP servers. # ``user_id`` is the listener identity component — pool-only # changes for OTHER users must not fire this callback. self._mcp_refresh_cb = self._on_mcp_tools_changed - mcp_client.add_listener(self._mcp_refresh_cb, user_id=self._mcp_user_id) + self._mcp_client.add_listener(self._mcp_refresh_cb, user_id=self._mcp_user_id) # Register for resource-change notifications. # ``user_id`` scopes the listener so pool-only resource # changes for OTHER users do not wake this session. self._mcp_resource_cb = self._on_mcp_resources_changed - mcp_client.add_resource_listener(self._mcp_resource_cb, user_id=self._mcp_user_id) + self._mcp_client.add_resource_listener(self._mcp_resource_cb, user_id=self._mcp_user_id) # Register for prompt-change notifications. # ``user_id`` scopes the listener so pool-only prompt changes # for OTHER users do not wake this session. self._mcp_prompt_cb = self._on_mcp_prompts_changed - mcp_client.add_prompt_listener(self._mcp_prompt_cb, user_id=self._mcp_user_id) + self._mcp_client.add_prompt_listener(self._mcp_prompt_cb, user_id=self._mcp_user_id) # Proactively warm this user's per-user OAuth (oauth_user) pools so # their tools are present without a manual reconnect (e.g. after a # reboot/upgrade, or right after consent). Fire-and-forget — the # listeners registered just above deliver the catalog to this # session once each prime completes. No-op for users with no # consented oauth_user servers. - if self._mcp_user_id and hasattr(mcp_client, "prime_user_pools"): + if self._mcp_user_id and hasattr(self._mcp_client, "prime_user_pools"): try: - mcp_client.prime_user_pools(self._mcp_user_id) + self._mcp_client.prime_user_pools(self._mcp_user_id) except Exception: log.debug( "mcp prime_user_pools scheduling failed user=%s", @@ -1497,7 +1521,13 @@ class ChatSession: self._tool_search_threshold = tool_search_threshold self._tool_search_max_results = tool_search_max_results self._tool_search: ToolSearchManager | None = None - if tool_search == "on" or ( + if self._persona_tool_search_blocked(): + # Persona visibility set without ``tool_search`` = a HARD set + # (locked decision 3): the whole pathway is disabled, including + # the provider-native defer_loading mode where there is no + # synthetic tool name to filter out. + pass + elif tool_search == "on" or ( tool_search == "auto" and len(self._tools) > tool_search_threshold ): # always_on_names is the set of builtin tools present in @@ -2026,32 +2056,43 @@ class ChatSession: def _save_config(self) -> None: """Persist LLM-affecting config so resumed workstreams behave identically.""" - save_workstream_config( - self._ws_id, - { - "model": self.model, - "model_alias": self._model_alias or "", - "temperature": str(self.temperature), - "reasoning_effort": self.reasoning_effort, - "max_tokens": str(self.max_tokens), - "instructions": self.instructions or "", - "creative_mode": str(self.creative_mode), - "skill": self._skill_name or "", - # SKILL.md spec ``$ARGUMENTS`` payload (#572). Stored - # so a resumed workstream re-renders the skill with the - # same args the original load supplied — otherwise the - # rehydrate path would silently swap to empty args. - "skill_arguments": self._skill_arguments, - "token_budget": str(self._token_budget), - "applied_skill_id": self._applied_skill_id, - "applied_skill_version": str(self._applied_skill_version), - # Snapshot isolation: skill content is persisted per-workstream so that - # edits to the skill between sessions don't break resume. This duplicates - # up to 32KB per active workstream — acceptable trade-off for correctness. - "applied_skill_content": self._applied_skill_content, - "notify_on_complete": self._notify_on_complete, - }, - ) + config = { + "model": self.model, + "model_alias": self._model_alias or "", + "temperature": str(self.temperature), + "reasoning_effort": self.reasoning_effort, + "max_tokens": str(self.max_tokens), + "instructions": self.instructions or "", + "skill": self._skill_name or "", + # SKILL.md spec ``$ARGUMENTS`` payload (#572). Stored + # so a resumed workstream re-renders the skill with the + # same args the original load supplied — otherwise the + # rehydrate path would silently swap to empty args. + "skill_arguments": self._skill_arguments, + "token_budget": str(self._token_budget), + "applied_skill_id": self._applied_skill_id, + "applied_skill_version": str(self._applied_skill_version), + # Snapshot isolation: skill content is persisted per-workstream so that + # edits to the skill between sessions don't break resume. This duplicates + # up to 32KB per active workstream — acceptable trade-off for correctness. + "applied_skill_content": self._applied_skill_content, + "notify_on_complete": self._notify_on_complete, + } + # Persona stamp: written only when a persona was resolved at + # creation. Legacy pre-persona workstreams must never get + # back-stamped by an incidental _save_config call (/model etc.) — + # absence of the keys IS their persona state. + if self._persona_name: + config.update( + PersonaSnapshot( + name=self._persona_name, + prompt=self._persona_prompt, + tools=self._persona_tools, + mcp=self._persona_mcp, + memory=self._persona_memory, + ).to_config() + ) + save_workstream_config(self._ws_id, config) def _load_skills(self) -> None: """Load skills from storage. Called once at init and on /skill.""" @@ -2451,7 +2492,11 @@ class ChatSession: def _rebuild_tool_search(self) -> None: """Reconstruct ToolSearchManager, preserving expanded tools.""" old_expanded = self._tool_search.get_expanded_names() if self._tool_search else [] - if self._tool_search_setting == "on" or ( + if self._persona_tool_search_blocked(): + # Hard persona set — the pathway stays disabled across MCP + # catalog refreshes too (mirrors the constructor gate). + self._tool_search = None + elif self._tool_search_setting == "on" or ( self._tool_search_setting == "auto" and len(self._tools) > self._tool_search_threshold ): self._tool_search = ToolSearchManager( @@ -3003,6 +3048,24 @@ class ChatSession: ) # Restore persisted config config = load_workstream_config(ws_id) + # Adopt the persona stamp of the workstream being resumed — a + # non-fork resume adopts the target's identity, so keeping this + # session's creation-time stamp would clobber the target's on the + # next _save_config. The prompt/visibility/memory levers reapply + # via the _init_system_messages() recompose below and the per-call + # visibility filter; the MCP lever is construction-time only (the + # startup resume paths — SessionManager.open, CLI --resume — + # construct with the stamp, so only a mid-session REPL /resume + # keeps the constructing persona's MCP surface). A fork keeps its + # own creation-time stamp. Corrupt stamps raise — never silently + # rewritten. + if not fork: + snap = snapshot_from_config(config or {}) + self._persona_name = snap.name if snap else "" + self._persona_prompt = snap.prompt if snap else "" + self._persona_tools = snap.tools if snap else None + self._persona_mcp = snap.mcp if snap else True + self._persona_memory = snap.memory if snap else True if config: # Restore model via registry (same path as /model command) saved_alias = config.get("model_alias", "") @@ -3053,8 +3116,6 @@ class ChatSession: self.max_tokens = int(config["max_tokens"]) if "instructions" in config: self.instructions = config["instructions"] or None - if "creative_mode" in config: - self.creative_mode = config["creative_mode"] == "True" if "skill" in config or "template" in config: self._skill_name = config.get("skill") or config.get("template") or None # Restore #572's invocation-args payload BEFORE @@ -3142,7 +3203,7 @@ class ChatSession: message_count=len(self.messages), ) - if self._mem_cfg.nudges and should_nudge( + if self._nudges_enabled("resume") and should_nudge( "resume", self._metacog_state, message_count=len(self.messages), @@ -3153,12 +3214,24 @@ class ChatSession: self._init_system_messages() return True + def _nudges_enabled(self, nudge_type: str) -> bool: + """Config gate + persona lever 4 for metacognitive nudges. + + Memory-directed nudge types (``MEMORY_NUDGE_TYPES``) are suppressed + when the persona's memory is off — their copy directs the model at + the memory tool the persona hides. Behavioural nudges (repeat, + compaction, idle children, watches) stay on the config gate only. + """ + if not self._mem_cfg.nudges: + return False + return self._persona_memory or nudge_type not in MEMORY_NUDGE_TYPES + def _init_system_messages(self) -> None: """Build the system/developer prefix messages. - Developer message contains tool patterns (or creative writing - instructions when creative_mode is on), plus any user-supplied - instructions and memory reminders. + Developer message contains the composed system message (persona + base override included), plus any user-supplied instructions and + memory reminders. Uses copy-on-write: builds new lists locally, then assigns atomically so concurrent readers (e.g. background thread @@ -3168,78 +3241,57 @@ class ChatSession: # Refresh shared-workstream state so it stays current (banner, the # declaration below, and _maybe_note_new_participant's "already known" - # gate) regardless of which developer-message branch renders — a - # creative-mode compose must not leave a just-reset (or stale) flag - # unrefreshed just because it doesn't render the CONTEXT banner itself. + # gate) before the developer message renders. self._recompute_shared_state() # -- Developer message -- - if self.creative_mode: - dev_parts = [ - "# Instructions", - "", - ( - "You are a creative writing partner. Use the analysis channel to " - "think through structure, voice, and intent before drafting." - ), - "", - "Craft principles:", - "- Ground scenes in concrete sensory detail — what is seen, heard, felt.", - ( - "- Vary rhythm. Short sentences hit hard. Longer ones carry the reader " - "through texture and nuance, building toward something." - ), - ( - "- Dialogue should do at least two things: reveal character AND advance " - "plot or tension. Cut anything that's just exchanging information." - ), - ( - "- Earn your abstractions. Don't say 'she felt sad' — show the thing " - "that makes the reader feel it." - ), - "- Trust subtext. Leave room for the reader.", - "", - ( - "Match the user's genre and tone. If they want literary fiction, write " - "literary fiction. If they want pulp, write pulp with conviction. " - "Never condescend to the form." - ), - ] - else: - # Compose system message from modular components - tool_names = frozenset(t["function"]["name"] for t in self._tools if "function" in t) - # Load DB prompt policies if storage is available - db_policies: list[dict[str, Any]] = [] - try: - storage = get_storage() - if storage: - db_policies = storage.list_prompt_policies() - except Exception: - log.debug("Failed to load prompt policies from storage", exc_info=True) - now = datetime.now().astimezone() - # Round to the top of the hour. Anthropic and OpenAI both cache the - # system prefix; minute-precision time stamps invalidated the cache - # on every turn that crossed a minute boundary. Hour-precision still - # gives the model time-of-day awareness without paying for a full - # prefix recompute every ~60 seconds. - ctx = SessionContext( - current_datetime=now.strftime("%Y-%m-%dT%H:00"), - timezone=now.tzname() or "UTC", - username=self._username or self._user_id or "unknown", - project=self._project_name, - shared=self._shared_workstream, - ws_id=self._ws_id, - project_id=self._project_id, - ) - composed = compose_system_message( - client_type=self._client_type, - context=ctx, - available_tools=tool_names, - policies=["web_search"], - db_policies=db_policies, - kind=self._kind, - ) - dev_parts = [composed] + # Compose system message from modular components. The name set + # runs through the persona visibility filter (levers 2+4) so the + # TOOLS block self-suppresses on an empty envelope, tool-gated + # policies drop with their tool, and the memory-tool advisory + # below disappears when the persona hides ``memory``. + tool_names = frozenset( + name + for t in self._tools + if "function" in t and self._persona_tool_visible(name := t["function"]["name"]) + ) + # Load DB prompt policies if storage is available + db_policies: list[dict[str, Any]] = [] + try: + storage = get_storage() + if storage: + db_policies = storage.list_prompt_policies() + except Exception: + log.debug("Failed to load prompt policies from storage", exc_info=True) + now = datetime.now().astimezone() + # Round to the top of the hour. Anthropic and OpenAI both cache the + # system prefix; minute-precision time stamps invalidated the cache + # on every turn that crossed a minute boundary. Hour-precision still + # gives the model time-of-day awareness without paying for a full + # prefix recompute every ~60 seconds. + ctx = SessionContext( + current_datetime=now.strftime("%Y-%m-%dT%H:00"), + timezone=now.tzname() or "UTC", + username=self._username or self._user_id or "unknown", + project=self._project_name, + shared=self._shared_workstream, + ws_id=self._ws_id, + project_id=self._project_id, + ) + composed = compose_system_message( + client_type=self._client_type, + context=ctx, + available_tools=tool_names, + policies=["web_search"], + db_policies=db_policies, + kind=self._kind, + # Persona lever 1: replaces ONLY the BASE module. ENV / + # CONTEXT / TOOLS / POLICIES keep composing, so mandatory + # prompt policies ride on top of every persona — unlike the + # removed /creative fork, which bypassed composition entirely. + base_override=self._persona_prompt or None, + ) + dev_parts = [composed] # Capability-gated system-prompt additions. Resolve caps once here, via # _resolve_capabilities (NOT _get_capabilities) so we don't populate # self._cached_capabilities during __init__ — that would make later @@ -3267,8 +3319,15 @@ class ChatSession: # fold-only operator declaration above. if self._shared_workstream: dev_parts.append("\n\n" + build_shared_workstream_declaration(self._sender_label_nonce)) - # Tool search hint (client-side mode only — native mode needs no hint). - if self._tool_search and caps is not None and not caps.supports_tool_search: + # Tool search hint (client-side mode only — native mode needs no + # hint). Persona visibility sets force client-side mode even on + # native-capable providers (see _get_active_tools), so they get + # the hint too. + if ( + self._tool_search + and caps is not None + and (not caps.supports_tool_search or self._persona_tools is not None) + ): dev_parts.append( "\n\nAdditional tools are available via tool_search. " "Use it when you need a capability not in your current tool set." @@ -3386,7 +3445,14 @@ class ChatSession: # Composed against a real user-message query at least once; send() # uses this to know the deferred first-turn recompose is done. self._system_composed_with_context = True - visible_mems, candidate_source = self._select_memory_candidates(context) + # Persona lever 4 (own hands only): memory-off suppresses recalled- + # memory injection here, the nudges at their producer sites, and the + # memory tool via the visibility filter. Task agents keep their own + # memory tool (``_task_tools`` is not persona-filtered), and + # compaction spill/markers are session mechanics — never gated. + visible_mems, candidate_source = ( + self._select_memory_candidates(context) if self._persona_memory else ([], "") + ) if visible_mems: thr = self._bm25_rerank_threshold() relevant = score_memories( @@ -4321,6 +4387,44 @@ class ChatSession: # -- tool search helpers -------------------------------------------------- + def _persona_tool_search_blocked(self) -> bool: + """True when the persona's visibility set is HARD (no ``tool_search``). + + A hard set disables the whole tool-search pathway (locked decision + 3) — the constructor and ``_rebuild_tool_search`` both skip + ToolSearchManager construction, which also covers provider-native + defer_loading mode where no synthetic tool name exists to filter. + """ + return self._persona_tools is not None and "tool_search" not in self._persona_tools + + def _persona_tool_visible(self, name: str) -> bool: + """Whether the persona's envelope lets ``name`` reach the wire/prompt. + + - memory-off (lever 4) hides the ``memory`` tool from the persona's + own hands regardless of the visibility set; + - a ``None`` set is unrestricted; + - an explicit set is exact, EXCEPT tools the model already loaded + through ``tool_search`` (the session's discovered set unions with + the allowlist — the escape hatch stays honest: once advertised as + loaded, a tool doesn't vanish from the wire). + """ + if not self._persona_memory and name == "memory": + return False + if self._persona_tools is None: + return True + if name in self._persona_tools: + return True + ts = self._tool_search + return ts is not None and name in ts.get_expanded_names() + + def _apply_persona_visibility(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Filter a tool-definition list to the persona's visible set.""" + if self._persona_tools is None and self._persona_memory: + return tools + return [ + t for t in tools if self._persona_tool_visible(t.get("function", {}).get("name", "")) + ] + def _get_active_tools(self) -> list[dict[str, Any]] | None: """Return the tool list to send to the LLM. @@ -4337,18 +4441,27 @@ class ChatSession: MCP tool gating: ``read_resource`` is removed when no MCP servers expose resources; ``use_prompt`` is removed when none expose prompts. + + Persona gating (levers 2+4) runs LAST so the wire never advertises + a tool the visibility set hides — whatever branch produced the list. + With a persona visibility set, tool search always runs in + client-side mode (see ``_get_capabilities`` note below): native + defer_loading would strip deferred tools here before the provider + could offer them for discovery. """ - if self.creative_mode: - return None caps = self._get_capabilities() if not self._tool_search: tools = self._tools else: - if caps.supports_tool_search: + if caps.supports_tool_search and self._persona_tools is None: # Provider handles defer_loading — send all tools tools = self._tools else: - # Client-side fallback: visible tools + search tool + # Client-side fallback: visible tools + search tool. + # Forced for persona visibility sets — the synthetic + # tool_search + expanded-names union is how a soft set + # grows, and the persona filter below would otherwise + # eat the provider's deferred catalog. visible = self._tool_search.get_visible_tools() tools = visible + [self._tool_search.get_search_tool_definition()] @@ -4369,12 +4482,16 @@ class ChatSession: ): tools = _without_tool(tools, "use_prompt") - return tools + return self._apply_persona_visibility(tools) def _get_deferred_names(self) -> frozenset[str] | None: """Return names of deferred tools for native provider search, or None.""" if not self._tool_search: return None + if self._persona_tools is not None: + # Persona visibility sets force client-side tool search — see + # _get_active_tools — so never hand the provider deferred names. + return None caps = self._get_capabilities() if not caps.supports_tool_search: return None # Client-side mode — no deferred names for provider @@ -6200,7 +6317,7 @@ class ChatSession: f"{GRAY}[request] model={self.model} " f"max_tokens={self.max_tokens} temp={self.temperature} " f"reasoning={self.reasoning_effort} " - f"tools={0 if self.creative_mode else len(self._get_active_tools() or [])}" + f"tools={len(self._get_active_tools() or [])}" f"{' (search)' if self._tool_search else ''}{RESET}" ) lines.append(f"{GRAY}[request] {len(msgs)} messages:{RESET}") @@ -6976,12 +7093,22 @@ class ChatSession: # A truncated carry is a cache miss, not a loss: storage keeps the # full transcript, so tell the model where the rest lives instead of - # leaving a bare cut. + # leaving a bare cut. The recall-tool pointer is emitted only when + # the persona's envelope actually shows the recall tool — an + # empty-toolset persona must not be pointed at a tool it can't call + # (mirrors the memory-advisory gating; compaction itself is never + # persona-gated). if carry_truncated: - summary += ( - "\n\nTruncated content above is not lost: the full text remains " - "in conversation history and the recall tool can retrieve it." - ) + if self._persona_tool_visible("recall"): + summary += ( + "\n\nTruncated content above is not lost: the full text remains " + "in conversation history and the recall tool can retrieve it." + ) + else: + summary += ( + "\n\nTruncated content above is not lost: the full text " + "remains in the stored conversation history." + ) # Honor a cancel — or a newer generation that superseded this send DURING # the (possibly only, possibly slow) summary call — before we mutate state. @@ -8158,7 +8285,7 @@ class ChatSession: else "Denied by user" ) user_feedback = None # feedback is in the denial_msg - if self._mem_cfg.nudges and should_nudge( + if self._nudges_enabled("denial") and should_nudge( "denial", self._metacog_state, message_count=len(self.messages), @@ -9662,7 +9789,9 @@ class ChatSession: # text changes shouldn't be load-bearing for correctness. if self._wake_source_tag: return None - if not self._mem_cfg.nudges: + # Every type this detector emits (start/correction/completion) is + # memory-directed, so the persona memory lever gates the whole pass. + if not self._nudges_enabled("start"): return None mem_count = self._visible_memory_count() msg_count = len(self.messages) + 1 @@ -9905,7 +10034,7 @@ class ChatSession: # the tool batch. Cooldown gating in should_nudge keeps this to one # nudge per batch even with many failing tools. if ( - self._mem_cfg.nudges + self._nudges_enabled("tool_error") and any(self._tool_error_flags.get(tc_id) for tc_id, _ in results) and should_nudge( "tool_error", @@ -10006,6 +10135,11 @@ class ChatSession: model = (args.get("model") or "").strip() target_node = (args.get("target_node") or "").strip() project = (args.get("project") or "").strip() + persona = (args.get("persona") or "").strip() + if persona: + persona_err = self._validate_child_persona(persona) + if persona_err: + return self._coord_tool_error(call_id, "spawn_workstream", persona_err) if initial_message: first_line = initial_message.splitlines()[0] preview_line = first_line[:120] + ("..." if len(first_line) > 120 else "") @@ -10016,6 +10150,8 @@ class ChatSession: preview_body = "" if skill: header_bits.append(f"skill={skill}") + if persona: + header_bits.append(f"persona={persona}") if target_node: header_bits.append(f"node={target_node}") header = " ".join(header_bits) @@ -10033,8 +10169,36 @@ class ChatSession: "model": model, "target_node": target_node, "project": project, + "persona": persona, } + def _validate_child_persona(self, persona: str) -> str: + """Prep-time gate for a spawn's ``persona`` arg. + + Returns an error string (empty = valid). Children are always + ``kind=interactive`` — see ``CoordinatorClient.spawn``. The + receiving node's create handler re-resolves and stamps; this gate + just turns an inevitable HTTP 400 into a clean tool error the + model can react to. Best-effort: a storage blip defers the + verdict to the create handler rather than blocking the spawn. + """ + try: + storage = get_storage() + if storage is None: + return "" + row = storage.get_persona_by_name(persona) + except Exception: + log.debug("spawn.persona_precheck_failed persona=%s", persona, exc_info=True) + return "" + if not row or not row.get("enabled", False): + return f"unknown or disabled persona: {persona!r}" + if "interactive" not in (row.get("applies_to_kinds") or []): + return ( + f"persona {persona!r} does not apply to interactive workstreams " + "(spawned children are always interactive)" + ) + return "" + def _exec_spawn_workstream(self, item: dict[str, Any]) -> tuple[str, str]: call_id = item["call_id"] try: @@ -10047,6 +10211,7 @@ class ChatSession: model=item["model"], target_node=item["target_node"], project=item["project"], + persona=item["persona"], ) except Exception as e: msg = f"Error: spawn_workstream failed: {e}" @@ -10134,6 +10299,7 @@ class ChatSession: name = self._coord_str_arg(raw, "name").strip() model = self._coord_str_arg(raw, "model").strip() target_node = self._coord_str_arg(raw, "target_node").strip() + persona = self._coord_str_arg(raw, "persona").strip() spec: dict[str, Any] = { "idx": idx, "initial_message": initial_message, @@ -10141,14 +10307,26 @@ class ChatSession: "name": name, "model": model, "target_node": target_node, + "persona": persona, } + if persona: + # Same prep-time gate as spawn_workstream, but surfaced as + # a per-row denial (partial-success semantics) rather than + # failing the whole batch. + persona_err = self._validate_child_persona(persona) + if persona_err: + spec["_error"] = persona_err normalised.append(spec) - if initial_message: + if spec.get("_error"): + preview_rows.append(f" {idx}. [invalid — {spec['_error']}]") + elif initial_message: first_line = initial_message.splitlines()[0] preview_line = first_line[:80] + ("..." if len(first_line) > 80 else "") tag_bits = [] if skill: tag_bits.append(f"skill={skill}") + if persona: + tag_bits.append(f"persona={persona}") if target_node: tag_bits.append(f"node={target_node}") tags = (" [" + ", ".join(tag_bits) + "]") if tag_bits else "" @@ -10224,6 +10402,7 @@ class ChatSession: name=spec["name"], model=spec["model"], target_node=spec["target_node"], + persona=spec["persona"], ) except Exception as e: denied.append({"idx": idx, "reason": f"spawn failed: {e}"}) @@ -15275,23 +15454,10 @@ class ChatSession: self.ui.on_info("Compaction cancelled.") elif cmd == "/creative": - self.creative_mode = not self.creative_mode - self._init_system_messages() - self._save_config() - # Clear history when toggling ON if it contains tool messages, - # because the API rejects tool-call history without tool definitions - if self.creative_mode and any( - m.tool_calls or m.role is Role.TOOL for m in self.messages - ): - self.messages.clear() - self._read_files.clear() - self._msg_tokens.clear() - self.ui.on_info( - "[history cleared — creative mode is incompatible with tool history]" - ) - state = "on" if self.creative_mode else "off" + # Removed in 1.7 — the writer persona replaces it (issue #683). self.ui.on_info( - f"Creative mode: {bold(state)} (tools {'disabled' if self.creative_mode else 'enabled'})" + "/creative was removed. Start a session with the 'writer' " + "persona instead: turnstone --persona writer" ) elif cmd == "/debug": @@ -15395,7 +15561,6 @@ class ChatSession: " /model [alias] Show/switch model (alias from config)", " /raw Toggle reasoning content display", " /reason [low|med|high] Set/show reasoning effort", - " /creative Toggle creative writing mode (no tools)", " /debug Toggle raw SSE delta logging", " /mcp [refresh [server]] List or refresh MCP tools, resources, and prompts", " /help Show this help", diff --git a/turnstone/core/session_manager.py b/turnstone/core/session_manager.py index 20cb3867..09f2dcbd 100644 --- a/turnstone/core/session_manager.py +++ b/turnstone/core/session_manager.py @@ -17,6 +17,7 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Protocol from turnstone.core.log import get_logger +from turnstone.core.personas import snapshot_from_config from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState if TYPE_CHECKING: @@ -344,6 +345,7 @@ class SessionManager: client_type: str = "", parent_ws_id: str | None = None, project_id: str | None = None, + persona: str = "", defer_emit_created: bool = False, **extra_session_kwargs: Any, ) -> Workstream: @@ -390,6 +392,7 @@ class SessionManager: name=effective_name, parent_ws_id=parent_ws_id, project_id=project_id, + persona=persona, ) if evicted is not None: @@ -410,6 +413,7 @@ class SessionManager: kind=self.kind, parent_ws_id=parent_ws_id, project_id=project_id, + persona=persona, skill_id=skill_id, skill_version=skill_version, ) @@ -625,6 +629,7 @@ class SessionManager: name=row.get("name") or f"ws-{ws_id[:4]}", parent_ws_id=row.get("parent_ws_id"), project_id=row.get("project_id"), + persona=row.get("persona") or "", ) if evicted is not None: @@ -660,8 +665,23 @@ class SessionManager: ) saved_alias = None + # Persona snapshot rides the same pre-construction lane as + # the saved alias: the constructor applies the four levers + # (tool merge, MCP gate, composition) inside __init__, so + # the stamp must land as a kwarg — resume() is too late. + # A corrupt/partial stamp raises here (loud construction + # error), never silently reverting to a default envelope. + # No stamp = legacy pre-persona workstream: the kwarg is + # omitted entirely so factories that predate it keep working. + persona_snapshot = snapshot_from_config(saved_cfg or {}) + extra_build_kwargs: dict[str, Any] = {} + if persona_snapshot is not None: + extra_build_kwargs["persona_snapshot"] = persona_snapshot + try: - ws.session = self._adapter.build_session(ws, model=saved_alias) + ws.session = self._adapter.build_session( + ws, model=saved_alias, **extra_build_kwargs + ) except Exception: # Clean up the UI the adapter built before re-raising # so any listener/lock resources are released. @@ -1125,6 +1145,7 @@ class SessionManager: name: str, parent_ws_id: str | None = None, project_id: str | None = None, + persona: str = "", ) -> tuple[Workstream, Workstream | None]: """Install a placeholder ``Workstream`` under ``self._lock``. @@ -1176,6 +1197,7 @@ class SessionManager: ws.user_id = user_id ws.parent_ws_id = parent_ws_id if parent_ws_id else None ws.project_id = project_id if project_id else None + ws.persona = persona try: ws.ui = self._adapter.build_ui(ws) except Exception: diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py index 95b40f1d..a233e028 100644 --- a/turnstone/core/session_routes.py +++ b/turnstone/core/session_routes.py @@ -2480,9 +2480,71 @@ def make_create_handler( if skill_data and skill_data.get("template_id") else "" ) + + # --- Persona resolution (resolve ONCE, stamp forever) -------- + # Same gate shape as the skill lookup above: an explicit name + # must exist, be enabled, and support this kind (400 + # otherwise); an empty name resolves to the kind's default + # persona. A pre-seed database (no default persona) creates + # unstamped — legacy behavior, byte-identical to the + # engineer/orchestrator defaults. Resume skips resolution: + # the resumed session restores its own stamp from config. + body_persona_raw = body.get("persona") or "" + body_persona = ( + body_persona_raw.strip() if isinstance(body_persona_raw, str) else "" + ) + persona_row: dict[str, Any] | None = None + if not (isinstance(resume_ws_id_raw, str) and resume_ws_id_raw): + from turnstone.core.storage._registry import get_storage as _get_storage + + if body_persona: + _st = _get_storage() + if _st is None: + return JSONResponse({"error": "storage unavailable"}, status_code=503) + persona_row = await asyncio.to_thread( + _st.get_persona_by_name, body_persona + ) + if not persona_row or not persona_row.get("enabled", False): + return JSONResponse( + {"error": f"Persona not found or disabled: {body_persona}"}, + status_code=400, + ) + if mgr.kind.value not in (persona_row.get("applies_to_kinds") or []): + return JSONResponse( + { + "error": ( + f"Persona {body_persona!r} does not apply to " + f"kind {mgr.kind.value!r}" + ) + }, + status_code=400, + ) + else: + # Default-persona lookup is best-effort: a storage blip + # here degrades to an unstamped (legacy) create, which + # is behavior-identical to the shipped defaults — + # never a reason to fail the create. + try: + _st = _get_storage() + if _st is not None: + persona_row = await asyncio.to_thread( + _st.get_default_persona, mgr.kind.value + ) + except Exception: + log.debug("ws.create.default_persona_lookup_failed", exc_info=True) + persona_row = None + kwargs = cfg.create_build_kwargs( request, body, uid, skill_data, skill_id_resolved, applied_skill_version ) + if persona_row is not None: + from turnstone.core.personas import snapshot_from_persona + + # ``persona`` is SessionManager.create's explicit param + # (Workstream attr + workstreams row); the snapshot rides + # **extra_session_kwargs into the session factory. + kwargs["persona"] = persona_row["name"] + kwargs["persona_snapshot"] = snapshot_from_persona(persona_row) # Deferred emit — committed below post-attachment- # validation. See handler docstring's Ordering invariants. ws = await asyncio.to_thread(mgr.create, defer_emit_created=True, **kwargs) diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index f95b6dc7..7d7cc3b9 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -906,6 +906,7 @@ class PostgreSQLBackend: kind: WorkstreamKind | str = WorkstreamKind.INTERACTIVE, parent_ws_id: str | None = None, project_id: str | None = None, + persona: str | None = None, ) -> None: from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -916,6 +917,7 @@ class PostgreSQLBackend: # filters remain correct. norm_parent = parent_ws_id if parent_ws_id else None norm_project = project_id if project_id else None + norm_persona = persona if persona else None # Use ON CONFLICT DO NOTHING to match SQLite's OR IGNORE semantics # and close the SELECT-then-INSERT TOCTOU window under concurrent # register_workstream calls for the same ws_id. @@ -932,6 +934,7 @@ class PostgreSQLBackend: kind=norm_kind, parent_ws_id=norm_parent, project_id=norm_project, + persona=norm_persona, created=now, updated=now, ) diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index 04863d7f..fea74ab7 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -630,6 +630,7 @@ class StorageBackend(Protocol): kind: WorkstreamKind | str = "interactive", parent_ws_id: str | None = None, project_id: str | None = None, + persona: str | None = None, ) -> None: """Create a workstreams row (no-op if already exists). @@ -637,8 +638,10 @@ class StorageBackend(Protocol): (``"interactive"`` / ``"coordinator"``); the storage edge validates the value and rejects unknown kinds with ``ValueError``. ``parent_ws_id`` is non-NULL for children spawned by a coordinator; - ``project_id`` is the attached project — both are normalized from the - empty string to ``None`` at the storage edge. + ``project_id`` is the attached project; ``persona`` is the slug the + workstream was created with (display carrier — the snapshot lives in + ``workstream_config``) — all normalized from the empty string to + ``None`` at the storage edge. """ ... diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 510a2740..c6f17fa6 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -1027,6 +1027,7 @@ class SQLiteBackend: kind: WorkstreamKind | str = WorkstreamKind.INTERACTIVE, parent_ws_id: str | None = None, project_id: str | None = None, + persona: str | None = None, ) -> None: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") # Kind validation at the storage edge — third of three layers @@ -1040,6 +1041,7 @@ class SQLiteBackend: # filters remain correct. norm_parent = parent_ws_id if parent_ws_id else None norm_project = project_id if project_id else None + norm_persona = persona if persona else None with self._conn() as conn: conn.execute( sa.insert(workstreams).prefix_with("OR IGNORE"), @@ -1056,6 +1058,7 @@ class SQLiteBackend: "kind": norm_kind, "parent_ws_id": norm_parent, "project_id": norm_project, + "persona": norm_persona, "created": now, "updated": now, }, diff --git a/turnstone/core/workstream.py b/turnstone/core/workstream.py index bac069e0..2a1859a8 100644 --- a/turnstone/core/workstream.py +++ b/turnstone/core/workstream.py @@ -123,6 +123,10 @@ class Workstream: # The project this workstream is attached to (None = none). Children # inherit the parent's project_id at spawn. project_id: str | None = None + # Slug of the persona the workstream was created with ("" = pre-persona). + # Display carrier only — the session applies the stamped snapshot from + # workstream_config, never this field. + persona: str = "" # Tombstone: set by ``SessionManager.close`` under ``_lock`` so a # racing ``set_state`` can detect the close before it overwrites # the persisted ``state='closed'`` row. Guarded by ``_lock``. diff --git a/turnstone/prompts/__init__.py b/turnstone/prompts/__init__.py index 480c44a9..1fb972ea 100644 --- a/turnstone/prompts/__init__.py +++ b/turnstone/prompts/__init__.py @@ -1,9 +1,9 @@ """System message composition harness. -Assembles modular system messages from BASE (persona), ENV (client surface), -CONTEXT (session variables), TOOLS (usage patterns), and POLICIES (behavioral -rules). Replaces the monolithic persona+tools section of -``ChatSession._init_system_messages()``. +Assembles modular system messages from BASE (kind framing, or a persona's +base_override), ENV (client surface), CONTEXT (session variables), TOOLS +(usage patterns), and POLICIES (behavioral rules). Replaces the monolithic +base+tools section of ``ChatSession._init_system_messages()``. """ from __future__ import annotations @@ -227,6 +227,7 @@ def compose_system_message( policies: list[str] | None = None, db_policies: list[dict[str, Any]] | None = None, kind: WorkstreamKind = WorkstreamKind.INTERACTIVE, + base_override: str | None = None, ) -> str: """Compose a system message from modular components. @@ -251,6 +252,10 @@ def compose_system_message( A coordinator session has a disjoint tool schema (see COORDINATOR_TOOLS), so composing it with the IC tools block would instruct the model to hallucinate tool calls that fail. + base_override: + Persona base prompt. When set it replaces the BASE module and + NOTHING else — ENV / CONTEXT / TOOLS / POLICIES keep composing, + so mandatory prompt policies ride on top of every persona. Returns ------- @@ -264,12 +269,16 @@ def compose_system_message( # either way, but ``.value`` access does not — normalise once here. kind = WorkstreamKind.from_raw(kind) - # 1. BASE — kind-specific persona. The default base.md frames the + # 1. BASE — kind-specific base framing. The default base.md frames the # model as an IC engineer ("you read before you edit, commits # you make..."); coordinators need an orchestrator framing # instead ("you decompose, delegate, monitor, synthesise"). - base_module = "base_coordinator.md" if kind == WorkstreamKind.COORDINATOR else "base.md" - parts.append(_load(base_module)) + # A persona's base_override replaces exactly this module. + if base_override is not None: + parts.append(base_override) + else: + base_module = "base_coordinator.md" if kind == WorkstreamKind.COORDINATOR else "base.md" + parts.append(_load(base_module)) # 2. ENV — exactly one, selected by client type. Coordinators # skip ENV: they orchestrate rather than render rich output to diff --git a/turnstone/sdk/console.py b/turnstone/sdk/console.py index c4c717bd..5647d54c 100644 --- a/turnstone/sdk/console.py +++ b/turnstone/sdk/console.py @@ -163,6 +163,7 @@ class AsyncTurnstoneConsole(_BaseClient): model: str = "", initial_message: str = "", skill: str = "", + persona: str = "", resume_ws: str = "", auto_approve: bool = False, auto_approve_tools: str = "", @@ -179,6 +180,8 @@ class AsyncTurnstoneConsole(_BaseClient): body["initial_message"] = initial_message if skill: body["skill"] = skill + if persona: + body["persona"] = persona if resume_ws: body["resume_ws"] = resume_ws if auto_approve: @@ -213,6 +216,7 @@ class AsyncTurnstoneConsole(_BaseClient): auto_approve_tools: str = "", initial_message: str = "", skill: str = "", + persona: str = "", resume_ws: str = "", target_node: str = "", user_id: str = "", @@ -241,6 +245,8 @@ class AsyncTurnstoneConsole(_BaseClient): body["initial_message"] = initial_message if skill: body["skill"] = skill + if persona: + body["persona"] = persona if resume_ws: body["resume_ws"] = resume_ws if target_node: @@ -1219,6 +1225,7 @@ class TurnstoneConsole: model: str = "", initial_message: str = "", skill: str = "", + persona: str = "", resume_ws: str = "", auto_approve: bool = False, auto_approve_tools: str = "", @@ -1231,6 +1238,7 @@ class TurnstoneConsole: model=model, initial_message=initial_message, skill=skill, + persona=persona, resume_ws=resume_ws, auto_approve=auto_approve, auto_approve_tools=auto_approve_tools, @@ -1254,6 +1262,7 @@ class TurnstoneConsole: auto_approve_tools: str = "", initial_message: str = "", skill: str = "", + persona: str = "", resume_ws: str = "", target_node: str = "", user_id: str = "", @@ -1269,6 +1278,7 @@ class TurnstoneConsole: auto_approve_tools=auto_approve_tools, initial_message=initial_message, skill=skill, + persona=persona, resume_ws=resume_ws, target_node=target_node, user_id=user_id, diff --git a/turnstone/sdk/server.py b/turnstone/sdk/server.py index e7f02716..e4222350 100644 --- a/turnstone/sdk/server.py +++ b/turnstone/sdk/server.py @@ -105,6 +105,7 @@ class AsyncTurnstoneServer(_BaseClient): auto_approve: bool = False, resume_ws: str = "", skill: str = "", + persona: str = "", initial_message: str = "", auto_approve_tools: str = "", user_id: str = "", @@ -123,6 +124,9 @@ class AsyncTurnstoneServer(_BaseClient): *initial_message* is also set, the server resolves the staged attachments onto that turn before its background worker dispatches. + + *persona* selects the persona the workstream is created with + (resolved and snapshotted server-side; empty = the kind default). """ body: dict[str, Any] = {} if name: @@ -135,6 +139,8 @@ class AsyncTurnstoneServer(_BaseClient): body["resume_ws"] = resume_ws if skill: body["skill"] = skill + if persona: + body["persona"] = persona if initial_message: body["initial_message"] = initial_message if auto_approve_tools: @@ -607,6 +613,7 @@ class TurnstoneServer: auto_approve: bool = False, resume_ws: str = "", skill: str = "", + persona: str = "", initial_message: str = "", auto_approve_tools: str = "", user_id: str = "", @@ -622,6 +629,7 @@ class TurnstoneServer: auto_approve=auto_approve, resume_ws=resume_ws, skill=skill, + persona=persona, initial_message=initial_message, auto_approve_tools=auto_approve_tools, user_id=user_id, diff --git a/turnstone/server.py b/turnstone/server.py index fe2ad7fc..7c6d7f73 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -105,6 +105,8 @@ if TYPE_CHECKING: from starlette.types import ASGIApp, Receive, Scope, Send + from turnstone.core.personas import PersonaSnapshot + # --------------------------------------------------------------------------- # Static assets — loaded once at startup from turnstone/ui/static/ # --------------------------------------------------------------------------- @@ -2962,6 +2964,34 @@ async def remove_project_member_endpoint(request: Request) -> JSONResponse: return JSONResponse({"status": "ok", "members": storage.list_project_members(project_id)}) +async def list_personas_endpoint(request: Request) -> JSONResponse: + """GET /v1/api/personas — enabled personas for creation pickers. + + Authenticated but deliberately gated by NO ``persona.*`` permission: + selecting a persona at creation is a user action, while ``persona.*`` + perms gate authoring (the console admin CRUD). Display fields only — + the levers (prompt / tools / toggles) stay server-side. + """ + from turnstone.core.storage import get_storage + + _uid, uerr = _project_request_uid(request) + if uerr: + return uerr + storage = get_storage() + rows = storage.list_personas() if storage else [] + personas = [ + { + "name": r["name"], + "display_name": r.get("display_name") or "", + "description": r.get("description") or "", + "applies_to_kinds": r.get("applies_to_kinds") or [], + "is_default": bool(r.get("is_default")), + } + for r in rows + ] + return JSONResponse({"personas": personas, "total": len(personas)}) + + async def auth_login(request: Request) -> Response: """POST /v1/api/auth/login — authenticate and return JWT.""" from turnstone.core.auth import handle_auth_login @@ -4354,6 +4384,7 @@ def create_app( remove_project_member_endpoint, methods=["DELETE"], ), + Route("/api/personas", list_personas_endpoint), Route("/api/auth/login", auth_login, methods=["POST"]), Route("/api/auth/logout", auth_logout, methods=["POST"]), Route("/api/auth/status", auth_status), @@ -4794,6 +4825,7 @@ def main() -> None: kind: WorkstreamKind = WorkstreamKind.INTERACTIVE, parent_ws_id: str | None = None, project_id: str = "", + persona_snapshot: PersonaSnapshot | None = None, ) -> ChatSession: assert ui is not None # Resolve the effective alias once and use it consistently @@ -4904,6 +4936,7 @@ def main() -> None: kind=kind, parent_ws_id=parent_ws_id, project_id=project_id, + persona_snapshot=persona_snapshot, ) # Create WatchRunner (periodic command polling, server-level) diff --git a/turnstone/tools/spawn_batch.json b/turnstone/tools/spawn_batch.json index 5bf16e5e..505ef69c 100644 --- a/turnstone/tools/spawn_batch.json +++ b/turnstone/tools/spawn_batch.json @@ -29,6 +29,10 @@ "target_node": { "type": "string", "description": "Optional node_id hint. Pinning is hard — if the named node has dropped the 120s registry heartbeat between list_nodes and this spawn, the item lands in denied[] with a `No available node for routing` reason. Omit unless the workload truly requires that node." + }, + "persona": { + "type": "string", + "description": "Optional persona name for the child (must apply to interactive workstreams). Snapshotted at spawn; invalid names land in denied[]. Omit for the interactive default — children never inherit this coordinator's persona." } }, "required": [] diff --git a/turnstone/tools/spawn_workstream.json b/turnstone/tools/spawn_workstream.json index 7223e6ca..9e475d48 100644 --- a/turnstone/tools/spawn_workstream.json +++ b/turnstone/tools/spawn_workstream.json @@ -27,6 +27,10 @@ "project": { "type": "string", "description": "Optional project_id to attach the child to, overriding the project it would otherwise inherit from this coordinator. Omit to inherit the coordinator's project (the common case). The child only gains project access if its owning user can access the project." + }, + "persona": { + "type": "string", + "description": "Optional persona name for the child (children are always interactive-kind, so the persona must apply to interactive workstreams). The persona is resolved and snapshotted at spawn time. Omit for the interactive default persona — the child never inherits this coordinator's persona." } }, "required": [] diff --git a/turnstone/tools/task_agent.json b/turnstone/tools/task_agent.json index 7ad28c39..1866c5a8 100644 --- a/turnstone/tools/task_agent.json +++ b/turnstone/tools/task_agent.json @@ -1,6 +1,6 @@ { "name": "task_agent", - "description": "Delegate a general-purpose task to an autonomous sub-agent. The agent can read, write, edit, search, and run commands but does NOT have access to memory, recall, watch, skill, or task_agent — it cannot save memories, search conversation history, set up watches, switch skills mid-task, or delegate further. All context must be in the prompt. Provide a clear, self-contained task description. Optionally pass `skill=` to run the sub-agent under a specific persona (the skill is fixed at invocation and cannot be changed mid-task); use `skill(action='search', query='...')` to find an appropriate name first. An empty `skill` value is acceptable — the sub-agent runs as a competent general-purpose task helper.", + "description": "Delegate a general-purpose task to an autonomous sub-agent. The agent can read, write, edit, search, and run commands but does NOT have access to memory, recall, watch, skill, or task_agent — it cannot save memories, search conversation history, set up watches, switch skills mid-task, or delegate further. All context must be in the prompt. Provide a clear, self-contained task description. Optionally pass `skill=` to run the sub-agent under a specific skill framing (fixed at invocation, cannot be changed mid-task); use `skill(action='search', query='...')` to find an appropriate name first. An empty `skill` value is acceptable — the sub-agent runs as a competent general-purpose task helper.", "parameters": { "type": "object", "properties": {