mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(personas): close review findings across the envelope, resume, and RBAC lanes
Provider search gating (replace-only): native web search now stands in for
a client web_search def that survived the persona visibility filter — on
both OpenAI surfaces and both injection lanes (web_search_options, the
server_side_tools loop, and _convert_tools' capability lane). A scribe or
any envelope hiding web_search stays search-free on search-capable models;
coordinators and tool-less utility calls stop receiving search too.
Resume stamp discipline: resume() loads config and parses the target's
stamp BEFORE touching session identity/history, so a corrupt stamp raises
with the session intact instead of half-adopting and then 'repairing' the
target's stamp on the next config save. The MCP lever now follows the
stamp on mid-session adoption: an MCP-off stamp drops the live surface in
place (listeners deregistered, toolsets reset); adopting an MCP-on stamp
into a session whose persona gated the client off is refused loudly (the
surface cannot be rebuilt post-construction). The REPL /resume handler
reports these errors instead of crashing the CLI.
Fail-closed default lane: a FAILED default-persona lookup at create is a
503 (routes) / clear exit (CLI) instead of silently degrading to the
unstamped stock envelope; a clean 'no default configured' still creates
legacy. resolve_persona_for_kind reports storage-unavailable distinctly
from unknown-persona.
Soft-set governance: tool_search expansion under a persona visibility set
recomposes the system prompt so tool-gated policy segments land with the
tool they gate. MCP resource/prompt catalogs gate on read_resource /
use_prompt visibility. Spawn judge/audit projections carry persona (the
human approval header already did). Active-list rows carry persona like
their project_id twin.
RBAC catalogs: persona.{create,read,write} join _VALID_PERMISSIONS and
the roles-editor sections, making the documented grant-outward path real.
Storage hardening: default-persona invariants move to a shared _utils
helper (validate + demote) with a pg advisory xact lock serializing
promotions and a post-promote single-default assertion; create maps the
unique-name race to the same ValueError as the pre-check; reads validate
JSON shape loudly (naming the persona); serialize enforces size caps;
field validation runs before invariant checks so malformed input is a 400,
never a TypeError-500. org_id guards explicit null and caps at 64.
Also: base_override='' means 'no override' at the compose boundary;
persona tag flattened/capped before the spawn approval header; /creative
redirect resolves the writer persona before advertising it; memory-nudge
gating unified through _nudges_enabled.
Provider/row-shape tests updated to the new contracts (the old ones
pinned the injection hole and the pre-persona row shape).
This commit is contained in:
@@ -521,11 +521,16 @@ def test_active_list_row_shape_includes_unified_fields(storage):
|
|||||||
"parent_ws_id",
|
"parent_ws_id",
|
||||||
"user_id",
|
"user_id",
|
||||||
"project_id",
|
"project_id",
|
||||||
|
"persona",
|
||||||
}
|
}
|
||||||
assert row["name"] == "lifted-coord"
|
assert row["name"] == "lifted-coord"
|
||||||
assert row["kind"] == "coordinator"
|
assert row["kind"] == "coordinator"
|
||||||
assert row["parent_ws_id"] is None
|
assert row["parent_ws_id"] is None
|
||||||
assert row["user_id"] == "u1"
|
assert row["user_id"] == "u1"
|
||||||
|
# mgr.create without a persona kwarg stamps nothing at this layer
|
||||||
|
# (default resolution lives in the HTTP create handler), so the
|
||||||
|
# row carries the null slug — not a fabricated default.
|
||||||
|
assert row["persona"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_create_returns_ws_id_and_records_audit(storage):
|
def test_create_returns_ws_id_and_records_audit(storage):
|
||||||
|
|||||||
@@ -225,6 +225,22 @@ class TestRoles:
|
|||||||
assert resp.status_code == 200, resp.json()
|
assert resp.status_code == 200, resp.json()
|
||||||
assert "model.skills.write" in resp.json()["permissions"]
|
assert "model.skills.write" in resp.json()["permissions"]
|
||||||
|
|
||||||
|
def test_create_role_with_persona_permissions(self, client):
|
||||||
|
"""``persona.{create,read,write}`` (migration 063) are enumerated in
|
||||||
|
``_VALID_PERMISSIONS`` and pass role-create validation. Before the fix
|
||||||
|
they 400'd — a custom role could never carry a persona grant."""
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/api/admin/roles",
|
||||||
|
json=_role_payload(
|
||||||
|
name="personaeditor",
|
||||||
|
permissions="read,persona.create,persona.read,persona.write",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.json()
|
||||||
|
perms = resp.json()["permissions"]
|
||||||
|
for p in ("persona.create", "persona.read", "persona.write"):
|
||||||
|
assert p in perms
|
||||||
|
|
||||||
def test_permission_sections_js_covers_valid_permissions(self):
|
def test_permission_sections_js_covers_valid_permissions(self):
|
||||||
"""F-5: ``_PERMISSION_SECTIONS`` in governance.js mirrors
|
"""F-5: ``_PERMISSION_SECTIONS`` in governance.js mirrors
|
||||||
``_VALID_PERMISSIONS`` in console/server.py. A new perm added
|
``_VALID_PERMISSIONS`` in console/server.py. A new perm added
|
||||||
@@ -355,6 +371,19 @@ class TestRoles:
|
|||||||
assert role["display_name"] == "Senior Analyst"
|
assert role["display_name"] == "Senior Analyst"
|
||||||
assert role["permissions"] == "read,write,approve"
|
assert role["permissions"] == "read,write,approve"
|
||||||
|
|
||||||
|
def test_update_role_accepts_persona_permissions(self, client):
|
||||||
|
"""Editing a custom role to carry ``persona.*`` must validate (they were
|
||||||
|
rejected before 063 added them to ``_VALID_PERMISSIONS``)."""
|
||||||
|
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||||
|
role_id = create_resp.json()["role_id"]
|
||||||
|
resp = client.put(
|
||||||
|
f"/v1/api/admin/roles/{role_id}",
|
||||||
|
json={"permissions": "read,persona.read,persona.write"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.json()
|
||||||
|
perms = resp.json()["permissions"]
|
||||||
|
assert "persona.read" in perms and "persona.write" in perms
|
||||||
|
|
||||||
def test_update_nonexistent_role(self, client):
|
def test_update_nonexistent_role(self, client):
|
||||||
resp = client.put(
|
resp = client.put(
|
||||||
"/v1/api/admin/roles/nonexistent",
|
"/v1/api/admin/roles/nonexistent",
|
||||||
@@ -451,6 +480,20 @@ class TestRoleOverrides:
|
|||||||
assert "model.skills.write" in body["effective"]
|
assert "model.skills.write" in body["effective"]
|
||||||
assert body["grants"] == ["model.skills.write"]
|
assert body["grants"] == ["model.skills.write"]
|
||||||
|
|
||||||
|
def test_overrides_grant_persona_write(self, client, storage):
|
||||||
|
# persona.write is admin-default (063) but grantable to any builtin
|
||||||
|
# role via the overrides layer — the endpoint must accept it, not 400
|
||||||
|
# it as an unknown permission.
|
||||||
|
_seed_builtin_admin(storage, "read,write,admin.roles")
|
||||||
|
resp = client.put(
|
||||||
|
"/v1/api/admin/roles/builtin-admin/overrides",
|
||||||
|
json={"grant": ["persona.write"], "revoke": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.json()
|
||||||
|
body = resp.json()
|
||||||
|
assert "persona.write" in body["effective"]
|
||||||
|
assert body["grants"] == ["persona.write"]
|
||||||
|
|
||||||
def test_overrides_replace_semantics(self, client, storage):
|
def test_overrides_replace_semantics(self, client, storage):
|
||||||
_seed_builtin_admin(storage, "read,write,admin.roles")
|
_seed_builtin_admin(storage, "read,write,admin.roles")
|
||||||
client.put(
|
client.put(
|
||||||
|
|||||||
@@ -208,7 +208,26 @@ class TestBuildKwargs:
|
|||||||
def test_web_search_tool_injected_into_tools_list(self, provider: XAIProvider) -> None:
|
def test_web_search_tool_injected_into_tools_list(self, provider: XAIProvider) -> None:
|
||||||
# The inherited generalised injection in
|
# The inherited generalised injection in
|
||||||
# OpenAIResponsesProvider._build_kwargs walks server_side_tools;
|
# OpenAIResponsesProvider._build_kwargs walks server_side_tools;
|
||||||
# grok-4.3 declares `("web_search",)`.
|
# grok-4.3 declares `("web_search",)`. Injection is replace-only:
|
||||||
|
# it stands in for a client web_search def that survived the
|
||||||
|
# session's visibility filter, so the def must be present.
|
||||||
|
kwargs = provider._build_kwargs(
|
||||||
|
model="grok-4.3",
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
tools=[{"type": "function", "function": {"name": "web_search"}}],
|
||||||
|
max_tokens=512,
|
||||||
|
temperature=0.5,
|
||||||
|
reasoning_effort="low",
|
||||||
|
deferred_names=None,
|
||||||
|
capabilities=None,
|
||||||
|
replay_reasoning_to_model=False,
|
||||||
|
)
|
||||||
|
tools = kwargs.get("tools") or []
|
||||||
|
assert {"type": "web_search"} in tools
|
||||||
|
|
||||||
|
def test_web_search_not_injected_without_client_def(self, provider: XAIProvider) -> None:
|
||||||
|
# A request whose envelope hides web_search (persona visibility
|
||||||
|
# set, tool-less utility call) gains no native search.
|
||||||
kwargs = provider._build_kwargs(
|
kwargs = provider._build_kwargs(
|
||||||
model="grok-4.3",
|
model="grok-4.3",
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
@@ -221,7 +240,7 @@ class TestBuildKwargs:
|
|||||||
replay_reasoning_to_model=False,
|
replay_reasoning_to_model=False,
|
||||||
)
|
)
|
||||||
tools = kwargs.get("tools") or []
|
tools = kwargs.get("tools") or []
|
||||||
assert {"type": "web_search"} in tools
|
assert {"type": "web_search"} not in tools
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+73
-4
@@ -2752,6 +2752,23 @@ class TestOpenAIWebSearch:
|
|||||||
result = self.provider._apply_web_search(kwargs, caps, tools)
|
result = self.provider._apply_web_search(kwargs, caps, tools)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
def test_apply_web_search_no_op_when_client_def_absent(self) -> None:
|
||||||
|
"""Replace-only: a search model with a NON-EMPTY toolset that never
|
||||||
|
advertised web_search (a persona visibility set or coordinator
|
||||||
|
toolset) must NOT gain native search — the option stays off and the
|
||||||
|
tools pass through untouched. Contrast test_apply_web_search_with_
|
||||||
|
no_tools, which covers the tool-less utility-call case."""
|
||||||
|
caps = self.provider.get_capabilities("gpt-5-search-api")
|
||||||
|
assert caps.supports_web_search is True
|
||||||
|
kwargs: dict[str, Any] = {"model": "gpt-5-search-api"}
|
||||||
|
tools: list[dict[str, Any]] = [
|
||||||
|
{"type": "function", "function": {"name": "bash", "description": "Run bash"}},
|
||||||
|
{"type": "function", "function": {"name": "read_file", "description": "Read"}},
|
||||||
|
]
|
||||||
|
result = self.provider._apply_web_search(kwargs, caps, tools)
|
||||||
|
assert "web_search_options" not in kwargs
|
||||||
|
assert result is tools # unchanged, not filtered or replaced
|
||||||
|
|
||||||
def test_format_citations_appends_sources(self) -> None:
|
def test_format_citations_appends_sources(self) -> None:
|
||||||
"""url_citation annotations should be formatted as footnote sources."""
|
"""url_citation annotations should be formatted as footnote sources."""
|
||||||
ann = MagicMock()
|
ann = MagicMock()
|
||||||
@@ -2810,13 +2827,27 @@ class TestOpenAIWebSearch:
|
|||||||
assert "Sources:" not in result
|
assert "Sources:" not in result
|
||||||
|
|
||||||
def test_apply_web_search_with_no_tools(self) -> None:
|
def test_apply_web_search_with_no_tools(self) -> None:
|
||||||
"""Search model with tools=None should still inject web_search_options."""
|
"""No client web_search def ⇒ no injection (replace-only semantics).
|
||||||
|
|
||||||
|
A request that never advertised the web_search tool — persona
|
||||||
|
visibility set, coordinator toolset, or a tool-less utility call —
|
||||||
|
must not gain native search at the provider layer.
|
||||||
|
"""
|
||||||
caps = self.provider.get_capabilities("gpt-5-search-api")
|
caps = self.provider.get_capabilities("gpt-5-search-api")
|
||||||
kwargs: dict[str, Any] = {}
|
kwargs: dict[str, Any] = {}
|
||||||
result = self.provider._apply_web_search(kwargs, caps, None)
|
result = self.provider._apply_web_search(kwargs, caps, None)
|
||||||
assert "web_search_options" in kwargs
|
assert "web_search_options" not in kwargs
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
def test_apply_web_search_replaces_client_def(self) -> None:
|
||||||
|
"""With the client def present, it is filtered and the option set."""
|
||||||
|
caps = self.provider.get_capabilities("gpt-5-search-api")
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
tools = [{"type": "function", "function": {"name": "web_search"}}]
|
||||||
|
result = self.provider._apply_web_search(kwargs, caps, tools)
|
||||||
|
assert "web_search_options" in kwargs
|
||||||
|
assert result is None # the lone def was filtered away
|
||||||
|
|
||||||
def test_streaming_creates_with_web_search_options(self) -> None:
|
def test_streaming_creates_with_web_search_options(self) -> None:
|
||||||
"""Streaming with a search model should pass web_search_options."""
|
"""Streaming with a search model should pass web_search_options."""
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
@@ -4193,8 +4224,13 @@ class TestResponsesParamBuilding:
|
|||||||
)
|
)
|
||||||
assert kwargs["instructions"] == "Be helpful"
|
assert kwargs["instructions"] == "Be helpful"
|
||||||
|
|
||||||
def test_web_search_injected_with_no_tools(self) -> None:
|
def test_web_search_not_injected_with_no_tools(self) -> None:
|
||||||
"""Search-capable models get web_search tool even when tools=None."""
|
"""No client web_search def ⇒ no server-side web_search entry.
|
||||||
|
|
||||||
|
Replace-only semantics: a request whose envelope hides web_search
|
||||||
|
(persona visibility set, coordinator toolset, tool-less utility
|
||||||
|
call) must not gain native search at the provider layer.
|
||||||
|
"""
|
||||||
kwargs = self.provider._build_kwargs(
|
kwargs = self.provider._build_kwargs(
|
||||||
model="gpt-5-search-api",
|
model="gpt-5-search-api",
|
||||||
messages=[{"role": "user", "content": "Hi"}],
|
messages=[{"role": "user", "content": "Hi"}],
|
||||||
@@ -4204,10 +4240,43 @@ class TestResponsesParamBuilding:
|
|||||||
reasoning_effort="none",
|
reasoning_effort="none",
|
||||||
deferred_names=None,
|
deferred_names=None,
|
||||||
)
|
)
|
||||||
|
tool_types = [t.get("type") for t in kwargs.get("tools") or []]
|
||||||
|
assert "web_search" not in tool_types
|
||||||
|
|
||||||
|
def test_web_search_injected_with_client_def(self) -> None:
|
||||||
|
"""The server-side entry stands in for a surviving client def."""
|
||||||
|
kwargs = self.provider._build_kwargs(
|
||||||
|
model="gpt-5-search-api",
|
||||||
|
messages=[{"role": "user", "content": "Hi"}],
|
||||||
|
tools=[{"type": "function", "function": {"name": "web_search"}}],
|
||||||
|
max_tokens=4096,
|
||||||
|
temperature=0.5,
|
||||||
|
reasoning_effort="none",
|
||||||
|
deferred_names=None,
|
||||||
|
)
|
||||||
assert "tools" in kwargs
|
assert "tools" in kwargs
|
||||||
tool_types = [t.get("type") for t in kwargs["tools"]]
|
tool_types = [t.get("type") for t in kwargs["tools"]]
|
||||||
assert "web_search" in tool_types
|
assert "web_search" in tool_types
|
||||||
|
|
||||||
|
def test_web_search_not_injected_for_nonempty_toolset_without_def(self) -> None:
|
||||||
|
"""A non-empty toolset lacking web_search gains no native search.
|
||||||
|
|
||||||
|
Guards the _convert_tools lane: capability alone must not inject —
|
||||||
|
a persona visibility set or the coordinator toolset that hides
|
||||||
|
web_search stays search-free on search-capable models.
|
||||||
|
"""
|
||||||
|
kwargs = self.provider._build_kwargs(
|
||||||
|
model="gpt-5-search-api",
|
||||||
|
messages=[{"role": "user", "content": "Hi"}],
|
||||||
|
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||||
|
max_tokens=4096,
|
||||||
|
temperature=0.5,
|
||||||
|
reasoning_effort="none",
|
||||||
|
deferred_names=None,
|
||||||
|
)
|
||||||
|
tool_types = [t.get("type") for t in kwargs.get("tools") or []]
|
||||||
|
assert "web_search" not in tool_types
|
||||||
|
|
||||||
|
|
||||||
class TestResponsesCitationFormat:
|
class TestResponsesCitationFormat:
|
||||||
"""Test format_citations handles Responses API flat annotation format."""
|
"""Test format_citations handles Responses API flat annotation format."""
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ class TestListWorkstreamsTrustedTeamVisibility:
|
|||||||
"parent_ws_id",
|
"parent_ws_id",
|
||||||
"user_id",
|
"user_id",
|
||||||
"project_id",
|
"project_id",
|
||||||
|
"persona",
|
||||||
}
|
}
|
||||||
assert row["kind"] == "interactive"
|
assert row["kind"] == "interactive"
|
||||||
assert row["user_id"] == "user-shape"
|
assert row["user_id"] == "user-shape"
|
||||||
|
|||||||
+9
-4
@@ -888,15 +888,20 @@ def resolve_cli_persona_kwargs(
|
|||||||
return {}
|
return {}
|
||||||
if persona_arg:
|
if persona_arg:
|
||||||
row, err = resolve_persona_for_kind(storage, persona_arg, "interactive")
|
row, err = resolve_persona_for_kind(storage, persona_arg, "interactive")
|
||||||
if err or row is None:
|
if row is None:
|
||||||
print(red(err or f"Persona not found or disabled: {persona_arg}"))
|
print(red(err))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return {"persona": row["name"], "persona_snapshot": snapshot_from_persona(row)}
|
return {"persona": row["name"], "persona_snapshot": snapshot_from_persona(row)}
|
||||||
if storage is not None:
|
if storage is not None:
|
||||||
try:
|
try:
|
||||||
default_row = storage.get_default_persona("interactive")
|
default_row = storage.get_default_persona("interactive")
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
default_row = None
|
# A failed lookup must not silently start an unrestricted
|
||||||
|
# session — the operator may have promoted a restricted
|
||||||
|
# persona to default. (A clean None — no default configured —
|
||||||
|
# still yields the unstamped legacy session below.)
|
||||||
|
print(red(f"Default persona lookup failed: {exc}"))
|
||||||
|
sys.exit(1)
|
||||||
if default_row:
|
if default_row:
|
||||||
return {
|
return {
|
||||||
"persona": default_row["name"],
|
"persona": default_row["name"],
|
||||||
|
|||||||
@@ -6441,6 +6441,13 @@ _VALID_PERMISSIONS = frozenset(
|
|||||||
# Project deletion — destroys the container and its scoped memory, so
|
# Project deletion — destroys the container and its scoped memory, so
|
||||||
# it is a distinct capability from project.write (admin-default).
|
# it is a distinct capability from project.write (admin-default).
|
||||||
"project.delete",
|
"project.delete",
|
||||||
|
# Personas — workstream capability-envelope templates. Granted to
|
||||||
|
# builtin-admin via migration 063; grantable outward via custom
|
||||||
|
# roles / the overrides editor (selection at workstream creation is
|
||||||
|
# deliberately ungated — these gate authoring and management only).
|
||||||
|
"persona.create",
|
||||||
|
"persona.read",
|
||||||
|
"persona.write",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11941,7 +11948,9 @@ async def admin_create_persona(request: Request) -> JSONResponse:
|
|||||||
{
|
{
|
||||||
"persona_id": persona_id,
|
"persona_id": persona_id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"org_id": str(body.get("org_id", "")).strip(),
|
# ``or ""`` guards explicit JSON null (str(None) would persist
|
||||||
|
# the literal "None"); [:64] matches the org-handler caps.
|
||||||
|
"org_id": str(body.get("org_id") or "").strip()[:64],
|
||||||
"created_by": audit_uid,
|
"created_by": audit_uid,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2931,52 +2931,13 @@ function confirmDeleteProject(pid, name) {
|
|||||||
let _adminPersonas = [];
|
let _adminPersonas = [];
|
||||||
let _personaShelfWired = false;
|
let _personaShelfWired = false;
|
||||||
|
|
||||||
// FALLBACK builtin tool inventories per kind, for the visibility checklist.
|
// Builtin tool inventories per kind for the visibility checklist ride the
|
||||||
// The authoritative sets ride the GET /v1/api/admin/personas response
|
// GET /v1/api/admin/personas response (tool_inventory, derived server-side
|
||||||
// (tool_inventory, derived server-side from core/tools.py) and are cached in
|
// from core/tools.py) — deliberately NO hand-mirrored fallback constant
|
||||||
// _personaToolInventory; this constant only covers the render-before-load
|
// here, which would silently drift every time a tool ships. Until the
|
||||||
// window. "tool_search" is synthetic (not a builtin) but listed because its
|
// first list response lands the checklist renders empty; the free-text
|
||||||
// presence decides whether a visibility set is soft (expandable) or hard.
|
// "extra tools" input still accepts any name in that window.
|
||||||
let _personaToolInventory = null; // {interactive: [...], coordinator: [...]}
|
let _personaToolInventory = null; // {interactive: [...], coordinator: [...]}
|
||||||
const _PERSONA_TOOLS = {
|
|
||||||
interactive: [
|
|
||||||
"bash",
|
|
||||||
"diff_file",
|
|
||||||
"edit_file",
|
|
||||||
"memory",
|
|
||||||
"notify",
|
|
||||||
"read_file",
|
|
||||||
"read_resource",
|
|
||||||
"recall",
|
|
||||||
"search",
|
|
||||||
"skills",
|
|
||||||
"task_agent",
|
|
||||||
"tool_search",
|
|
||||||
"use_prompt",
|
|
||||||
"watch",
|
|
||||||
"web_fetch",
|
|
||||||
"web_search",
|
|
||||||
"write_file",
|
|
||||||
],
|
|
||||||
coordinator: [
|
|
||||||
"cancel_workstream",
|
|
||||||
"close_all_children",
|
|
||||||
"close_workstream",
|
|
||||||
"delete_workstream",
|
|
||||||
"inspect_workstream",
|
|
||||||
"list_nodes",
|
|
||||||
"list_workstreams",
|
|
||||||
"memory",
|
|
||||||
"notify",
|
|
||||||
"send_to_workstream",
|
|
||||||
"skills",
|
|
||||||
"spawn_batch",
|
|
||||||
"spawn_workstream",
|
|
||||||
"tasks",
|
|
||||||
"tool_search",
|
|
||||||
"wait_for_workstream",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadAdminPersonas() {
|
function loadAdminPersonas() {
|
||||||
authFetch("/v1/api/admin/personas")
|
authFetch("/v1/api/admin/personas")
|
||||||
@@ -3059,8 +3020,7 @@ function _renderPersonas(personas) {
|
|||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: "make default",
|
label: "make default",
|
||||||
title:
|
title: "Make this the kind default (demotes the incumbent)",
|
||||||
"Make this the kind default (demotes the incumbent)",
|
|
||||||
attrs: { "data-default-persona": p.persona_id },
|
attrs: { "data-default-persona": p.persona_id },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3158,8 +3118,31 @@ function _personaShelfWire() {
|
|||||||
.getElementById("pr-tools-mode")
|
.getElementById("pr-tools-mode")
|
||||||
.addEventListener("change", _personaToolsModeChanged);
|
.addEventListener("change", _personaToolsModeChanged);
|
||||||
document.getElementById("pr-kinds").addEventListener("change", function () {
|
document.getElementById("pr-kinds").addEventListener("change", function () {
|
||||||
// The checklist shows the ACTIVE kind's inventory.
|
// Re-render the ACTIVE kind's inventory carrying the checked names
|
||||||
_renderPersonaToolChecklist([]);
|
// over, so dual-kind tools (memory, notify, skills, tool_search)
|
||||||
|
// survive the flip; checked names outside the new kind's inventory
|
||||||
|
// migrate to the extra field instead of silently dropping from the
|
||||||
|
// allowlist the operator is editing.
|
||||||
|
const kept = [];
|
||||||
|
document
|
||||||
|
.querySelectorAll("#pr-tools-checklist [data-persona-tool]")
|
||||||
|
.forEach(function (input) {
|
||||||
|
if (input.checked) kept.push(input.value);
|
||||||
|
});
|
||||||
|
_renderPersonaToolChecklist(kept);
|
||||||
|
const kind = document.getElementById("pr-kinds").value || "interactive";
|
||||||
|
const known = (_personaToolInventory || {})[kind] || [];
|
||||||
|
const extra = document.getElementById("pr-tools-extra");
|
||||||
|
const extras = (extra.value || "")
|
||||||
|
.split(",")
|
||||||
|
.map(function (s) {
|
||||||
|
return s.trim();
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
kept.forEach(function (n) {
|
||||||
|
if (known.indexOf(n) < 0 && extras.indexOf(n) < 0) extras.push(n);
|
||||||
|
});
|
||||||
|
extra.value = extras.join(", ");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3171,7 +3154,7 @@ function _personaToolsModeChanged() {
|
|||||||
function _renderPersonaToolChecklist(checked) {
|
function _renderPersonaToolChecklist(checked) {
|
||||||
const kind = document.getElementById("pr-kinds").value || "interactive";
|
const kind = document.getElementById("pr-kinds").value || "interactive";
|
||||||
const host = document.getElementById("pr-tools-checklist");
|
const host = document.getElementById("pr-tools-checklist");
|
||||||
const inventory = _personaToolInventory || _PERSONA_TOOLS;
|
const inventory = _personaToolInventory || {};
|
||||||
const names = inventory[kind] || [];
|
const names = inventory[kind] || [];
|
||||||
host.replaceChildren();
|
host.replaceChildren();
|
||||||
names.forEach(function (name) {
|
names.forEach(function (name) {
|
||||||
@@ -3229,7 +3212,7 @@ function _personaFillToolsForm(allowlist) {
|
|||||||
} else {
|
} else {
|
||||||
modeSel.value = "list";
|
modeSel.value = "list";
|
||||||
const kind = document.getElementById("pr-kinds").value || "interactive";
|
const kind = document.getElementById("pr-kinds").value || "interactive";
|
||||||
const known = (_personaToolInventory || _PERSONA_TOOLS)[kind] || [];
|
const known = (_personaToolInventory || {})[kind] || [];
|
||||||
_renderPersonaToolChecklist(allowlist);
|
_renderPersonaToolChecklist(allowlist);
|
||||||
extra.value = allowlist
|
extra.value = allowlist
|
||||||
.filter(function (n) {
|
.filter(function (n) {
|
||||||
@@ -3303,9 +3286,7 @@ function submitPersonaShelf() {
|
|||||||
display_name: (
|
display_name: (
|
||||||
document.getElementById("pr-display-name").value || ""
|
document.getElementById("pr-display-name").value || ""
|
||||||
).trim(),
|
).trim(),
|
||||||
description: (
|
description: (document.getElementById("pr-description").value || "").trim(),
|
||||||
document.getElementById("pr-description").value || ""
|
|
||||||
).trim(),
|
|
||||||
base_prompt: prompt.trim() ? prompt : null,
|
base_prompt: prompt.trim() ? prompt : null,
|
||||||
tool_allowlist: _personaToolsFromForm(),
|
tool_allowlist: _personaToolsFromForm(),
|
||||||
mcp_enabled: document.getElementById("pr-mcp").checked,
|
mcp_enabled: document.getElementById("pr-mcp").checked,
|
||||||
|
|||||||
@@ -2086,13 +2086,18 @@ function _initSavedCoordTable() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// The PROJECT column resolves names from the shared projects cache,
|
// The PROJECT and PERSONA columns resolve names from the shared caches,
|
||||||
// which fills asynchronously — re-render once names arrive.
|
// which fill asynchronously — re-render once names arrive.
|
||||||
if (window.TurnstoneProjects) {
|
if (window.TurnstoneProjects) {
|
||||||
window.TurnstoneProjects.onProjectsChange(function () {
|
window.TurnstoneProjects.onProjectsChange(function () {
|
||||||
if (_coordTable) _coordTable.render();
|
if (_coordTable) _coordTable.render();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (window.TurnstonePersonas) {
|
||||||
|
window.TurnstonePersonas.onPersonasChange(function () {
|
||||||
|
if (_coordTable) _coordTable.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML inline-onclick wrappers — keep the global names the markup binds
|
// HTML inline-onclick wrappers — keep the global names the markup binds
|
||||||
|
|||||||
@@ -363,6 +363,10 @@ const _PERMISSION_SECTIONS = [
|
|||||||
"project.delete",
|
"project.delete",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Personas",
|
||||||
|
permissions: ["persona.create", "persona.read", "persona.write"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Coordinator",
|
label: "Coordinator",
|
||||||
permissions: ["coordinator.trust.send"],
|
permissions: ["coordinator.trust.send"],
|
||||||
|
|||||||
@@ -59,9 +59,7 @@ class PersonaSnapshot:
|
|||||||
return {
|
return {
|
||||||
"persona": self.name,
|
"persona": self.name,
|
||||||
"persona_prompt": self.prompt,
|
"persona_prompt": self.prompt,
|
||||||
"persona_tools": (
|
"persona_tools": (json.dumps(sorted(self.tools)) if self.tools is not None else "null"),
|
||||||
json.dumps(sorted(self.tools)) if self.tools is not None else "null"
|
|
||||||
),
|
|
||||||
"persona_mcp": "1" if self.mcp else "0",
|
"persona_mcp": "1" if self.mcp else "0",
|
||||||
"persona_memory": "1" if self.memory else "0",
|
"persona_memory": "1" if self.memory else "0",
|
||||||
}
|
}
|
||||||
@@ -77,8 +75,12 @@ def resolve_persona_for_kind(
|
|||||||
rule — the HTTP create handler, the CLI ``--persona`` path, and the
|
rule — the HTTP create handler, the CLI ``--persona`` path, and the
|
||||||
coordinator spawn precheck all consume this, so a future rule change
|
coordinator spawn precheck all consume this, so a future rule change
|
||||||
(per-org personas, a new kind) cannot leave the surfaces disagreeing.
|
(per-org personas, a new kind) cannot leave the surfaces disagreeing.
|
||||||
|
``storage is None`` reports a distinct storage-unavailable error — a
|
||||||
|
storage outage must never masquerade as "unknown persona".
|
||||||
"""
|
"""
|
||||||
row = storage.get_persona_by_name(name) if storage else None
|
if storage is None:
|
||||||
|
return None, "persona storage unavailable"
|
||||||
|
row = storage.get_persona_by_name(name)
|
||||||
if not row or not row.get("enabled", False):
|
if not row or not row.get("enabled", False):
|
||||||
return None, f"Persona not found or disabled: {name}"
|
return None, f"Persona not found or disabled: {name}"
|
||||||
if kind not in (row.get("applies_to_kinds") or []):
|
if kind not in (row.get("applies_to_kinds") or []):
|
||||||
|
|||||||
@@ -103,10 +103,15 @@ class OpenAIChatCompletionsProvider:
|
|||||||
"""
|
"""
|
||||||
if not caps.supports_web_search:
|
if not caps.supports_web_search:
|
||||||
return tools
|
return tools
|
||||||
if tools:
|
# Replace-only: native search stands in for the client ``web_search``
|
||||||
tools = [t for t in tools if t.get("function", {}).get("name") != "web_search"]
|
# def. When the request never advertised one (persona visibility set,
|
||||||
if not tools:
|
# coordinator toolset), injecting the option would hand the model a
|
||||||
tools = None
|
# capability its envelope hides.
|
||||||
|
if not tools or not any(t.get("function", {}).get("name") == "web_search" for t in tools):
|
||||||
|
return tools
|
||||||
|
tools = [t for t in tools if t.get("function", {}).get("name") != "web_search"]
|
||||||
|
if not tools:
|
||||||
|
tools = None
|
||||||
kwargs["web_search_options"] = {}
|
kwargs["web_search_options"] = {}
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|||||||
@@ -313,8 +313,14 @@ class OpenAIResponsesProvider:
|
|||||||
item["defer_loading"] = True
|
item["defer_loading"] = True
|
||||||
converted.append(item)
|
converted.append(item)
|
||||||
|
|
||||||
# Inject native web search tool
|
# Inject native web search — replace-only: it stands in for a client
|
||||||
if has_web_search_func or caps.supports_web_search:
|
# ``web_search`` def that survived the session's visibility filter.
|
||||||
|
# ``caps.supports_web_search`` alone must NOT inject, or a toolset
|
||||||
|
# whose envelope hides web_search (persona visibility set,
|
||||||
|
# coordinator toolset) gains native search on capable models; the
|
||||||
|
# capability-only lane for def-less requests is handled (and gated
|
||||||
|
# the same way) by the server_side_tools loop in _build_kwargs.
|
||||||
|
if has_web_search_func:
|
||||||
converted.append({"type": "web_search"})
|
converted.append({"type": "web_search"})
|
||||||
|
|
||||||
# Responses API requires a tool_search tool when defer_loading is used
|
# Responses API requires a tool_search tool when defer_loading is used
|
||||||
@@ -369,7 +375,16 @@ class OpenAIResponsesProvider:
|
|||||||
# ``{"type": "web_search"}`` appended. Subclasses (e.g.
|
# ``{"type": "web_search"}`` appended. Subclasses (e.g.
|
||||||
# ``XAIProvider``) opt their own provider-specific server tools
|
# ``XAIProvider``) opt their own provider-specific server tools
|
||||||
# into ``caps.server_side_tools`` and inherit this injection.
|
# into ``caps.server_side_tools`` and inherit this injection.
|
||||||
|
has_client_web_search = any(
|
||||||
|
t.get("function", {}).get("name") == "web_search" for t in tools or []
|
||||||
|
)
|
||||||
for tool_type in resolve_server_side_tools(caps):
|
for tool_type in resolve_server_side_tools(caps):
|
||||||
|
# Replace-only: the native web_search entry stands in for the
|
||||||
|
# client def. A request whose envelope hides ``web_search``
|
||||||
|
# (persona visibility set, coordinator toolset) gets no native
|
||||||
|
# search either.
|
||||||
|
if tool_type == "web_search" and not has_client_web_search:
|
||||||
|
continue
|
||||||
converted_tools = converted_tools or []
|
converted_tools = converted_tools or []
|
||||||
if not any(t.get("type") == tool_type for t in converted_tools):
|
if not any(t.get("type") == tool_type for t in converted_tools):
|
||||||
converted_tools.append({"type": tool_type})
|
converted_tools.append({"type": tool_type})
|
||||||
|
|||||||
+143
-47
@@ -115,7 +115,11 @@ from turnstone.core.metacognition import (
|
|||||||
should_nudge,
|
should_nudge,
|
||||||
)
|
)
|
||||||
from turnstone.core.nudge_queue import TOOL_DRAIN, USER_DRAIN, NudgeQueue
|
from turnstone.core.nudge_queue import TOOL_DRAIN, USER_DRAIN, NudgeQueue
|
||||||
from turnstone.core.personas import PersonaSnapshot, snapshot_from_config
|
from turnstone.core.personas import (
|
||||||
|
PersonaSnapshot,
|
||||||
|
resolve_persona_for_kind,
|
||||||
|
snapshot_from_config,
|
||||||
|
)
|
||||||
from turnstone.core.providers import create_provider
|
from turnstone.core.providers import create_provider
|
||||||
from turnstone.core.ratelimit import TokenBucket
|
from turnstone.core.ratelimit import TokenBucket
|
||||||
from turnstone.core.safety import is_command_blocked, sanitize_command
|
from turnstone.core.safety import is_command_blocked, sanitize_command
|
||||||
@@ -1454,6 +1458,11 @@ class ChatSession:
|
|||||||
# refresh callbacks stay inert (they all guard on _mcp_client).
|
# refresh callbacks stay inert (they all guard on _mcp_client).
|
||||||
# Task agents keep their native tools; only the MCP surface closes.
|
# Task agents keep their native tools; only the MCP surface closes.
|
||||||
self._mcp_client = mcp_client if self._persona_mcp else None
|
self._mcp_client = mcp_client if self._persona_mcp else None
|
||||||
|
# True when a real client was withheld by the persona gate (as
|
||||||
|
# opposed to no MCP in the deployment at all). Mid-session
|
||||||
|
# ``resume()`` refuses to adopt an MCP-on stamp in that case —
|
||||||
|
# the dropped surface cannot be rebuilt post-construction.
|
||||||
|
self._mcp_gated_off = mcp_client is not None and not self._persona_mcp
|
||||||
self._mcp_refresh_cb: Any = None # Callable | None (avoid import)
|
self._mcp_refresh_cb: Any = None # Callable | None (avoid import)
|
||||||
self._mcp_resource_cb: Any = None
|
self._mcp_resource_cb: Any = None
|
||||||
self._mcp_prompt_cb: Any = None
|
self._mcp_prompt_cb: Any = None
|
||||||
@@ -2636,6 +2645,39 @@ class ChatSession:
|
|||||||
log.debug("chat_session.coord_client_close_failed", exc_info=True)
|
log.debug("chat_session.coord_client_close_failed", exc_info=True)
|
||||||
self._cleanup_skill_resources()
|
self._cleanup_skill_resources()
|
||||||
|
|
||||||
|
def _drop_mcp_surface(self) -> None:
|
||||||
|
"""Drop the live MCP surface to match an adopted MCP-off stamp.
|
||||||
|
|
||||||
|
Mirror of the constructor's persona MCP gate for the one path that
|
||||||
|
changes the lever after construction: a mid-session ``resume()``
|
||||||
|
adopting an MCP-off stamp. Deregisters the three listeners (same
|
||||||
|
``_mcp_listener_user_id`` identity rule as ``close()``), drops the
|
||||||
|
client reference, and resets both toolsets to the builtin lists.
|
||||||
|
Only reachable on interactive sessions — coordinators never hold a
|
||||||
|
client. The caller rebuilds tool search and recomposes the prompt.
|
||||||
|
"""
|
||||||
|
if self._mcp_client is None:
|
||||||
|
return
|
||||||
|
if self._mcp_refresh_cb:
|
||||||
|
self._mcp_client.remove_listener(
|
||||||
|
self._mcp_refresh_cb, user_id=self._mcp_listener_user_id
|
||||||
|
)
|
||||||
|
self._mcp_refresh_cb = None
|
||||||
|
if self._mcp_resource_cb:
|
||||||
|
self._mcp_client.remove_resource_listener(
|
||||||
|
self._mcp_resource_cb, user_id=self._mcp_listener_user_id
|
||||||
|
)
|
||||||
|
self._mcp_resource_cb = None
|
||||||
|
if self._mcp_prompt_cb:
|
||||||
|
self._mcp_client.remove_prompt_listener(
|
||||||
|
self._mcp_prompt_cb, user_id=self._mcp_listener_user_id
|
||||||
|
)
|
||||||
|
self._mcp_prompt_cb = None
|
||||||
|
self._mcp_client = None
|
||||||
|
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, [])
|
||||||
|
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, [])
|
||||||
|
self._render_agent_tool_descriptions()
|
||||||
|
|
||||||
def _handle_mcp_refresh(self, arg: str) -> None:
|
def _handle_mcp_refresh(self, arg: str) -> None:
|
||||||
"""Handle ``/mcp refresh [server]``."""
|
"""Handle ``/mcp refresh [server]``."""
|
||||||
assert self._mcp_client is not None
|
assert self._mcp_client is not None
|
||||||
@@ -3031,7 +3073,27 @@ class ChatSession:
|
|||||||
turns = load_message_turns(ws_id)
|
turns = load_message_turns(ws_id)
|
||||||
if not turns:
|
if not turns:
|
||||||
return False
|
return False
|
||||||
|
# Load persisted config and parse the persona stamp BEFORE touching
|
||||||
|
# session identity/history: a corrupt stamp must raise while this
|
||||||
|
# session is still intact — the web /command surface reports the
|
||||||
|
# error and continues on the CURRENT workstream, and a half-adopted
|
||||||
|
# resume would run the corrupt target under this session's envelope,
|
||||||
|
# after which the next _save_config would "repair" the target's
|
||||||
|
# stamp with a persona the operator never chose for it.
|
||||||
|
config = load_workstream_config(ws_id)
|
||||||
|
snap: PersonaSnapshot | None = None
|
||||||
if not fork:
|
if not fork:
|
||||||
|
snap = snapshot_from_config(config or {})
|
||||||
|
if (snap.mcp if snap else True) and self._mcp_gated_off:
|
||||||
|
# The MCP lever is construction-time: narrowing is applied
|
||||||
|
# in place during adoption below, but a session whose
|
||||||
|
# persona dropped the client at construction cannot rebuild
|
||||||
|
# it — refuse rather than run out of step with the stamp.
|
||||||
|
raise ValueError(
|
||||||
|
f"cannot resume {ws_id} in place: its persona enables "
|
||||||
|
"MCP, which this session's persona dropped at "
|
||||||
|
"construction — open the workstream fresh instead"
|
||||||
|
)
|
||||||
self._ws_id = ws_id
|
self._ws_id = ws_id
|
||||||
# A non-fork resume repoints this session at a DIFFERENT existing
|
# A non-fork resume repoints this session at a DIFFERENT existing
|
||||||
# workstream's identity (fork keeps self._ws_id, so its nonces stay
|
# workstream's identity (fork keeps self._ws_id, so its nonces stay
|
||||||
@@ -3062,26 +3124,25 @@ class ChatSession:
|
|||||||
type(self._provider).__name__,
|
type(self._provider).__name__,
|
||||||
self.model,
|
self.model,
|
||||||
)
|
)
|
||||||
# Restore persisted config
|
# Restore persisted config (loaded and stamp-parsed above, before
|
||||||
config = load_workstream_config(ws_id)
|
# any session state was touched).
|
||||||
# Adopt the persona stamp of the workstream being resumed — a
|
# Adopt the persona stamp of the workstream being resumed — a
|
||||||
# non-fork resume adopts the target's identity, so keeping this
|
# non-fork resume adopts the target's identity, so keeping this
|
||||||
# session's creation-time stamp would clobber the target's on the
|
# session's creation-time stamp would clobber the target's on the
|
||||||
# next _save_config. The prompt/visibility/memory levers reapply
|
# next _save_config. The prompt/visibility/memory levers reapply
|
||||||
# via the _init_system_messages() recompose below and the per-call
|
# via the _init_system_messages() recompose below and the per-call
|
||||||
# visibility filter; the MCP lever is construction-time only (the
|
# visibility filter; the MCP lever narrows in place when the
|
||||||
# startup resume paths — SessionManager.open, CLI --resume —
|
# adopted stamp is MCP-off (_drop_mcp_surface below — widening was
|
||||||
# construct with the stamp, so only a mid-session REPL /resume
|
# refused before adoption). A fork keeps its own creation-time
|
||||||
# keeps the constructing persona's MCP surface). A fork keeps its
|
# stamp. Corrupt stamps raise — never silently rewritten.
|
||||||
# own creation-time stamp. Corrupt stamps raise — never silently
|
|
||||||
# rewritten.
|
|
||||||
if not fork:
|
if not fork:
|
||||||
snap = snapshot_from_config(config or {})
|
|
||||||
self._persona_name = snap.name if snap else ""
|
self._persona_name = snap.name if snap else ""
|
||||||
self._persona_prompt = snap.prompt if snap else ""
|
self._persona_prompt = snap.prompt if snap else ""
|
||||||
self._persona_tools = snap.tools if snap else None
|
self._persona_tools = snap.tools if snap else None
|
||||||
self._persona_mcp = snap.mcp if snap else True
|
self._persona_mcp = snap.mcp if snap else True
|
||||||
self._persona_memory = snap.memory if snap else True
|
self._persona_memory = snap.memory if snap else True
|
||||||
|
if not self._persona_mcp and self._mcp_client is not None:
|
||||||
|
self._drop_mcp_surface()
|
||||||
# Re-gate tool search under the adopted stamp: a hard set must
|
# Re-gate tool search under the adopted stamp: a hard set must
|
||||||
# drop the ToolSearchManager (else the recomposed prompt keeps
|
# drop the ToolSearchManager (else the recomposed prompt keeps
|
||||||
# the tool_search hint and the expanded-names escape hatch keeps
|
# the tool_search hint and the expanded-names escape hatch keeps
|
||||||
@@ -3240,9 +3301,10 @@ class ChatSession:
|
|||||||
|
|
||||||
Memory-directed nudge types (``MEMORY_NUDGE_TYPES``) are suppressed
|
Memory-directed nudge types (``MEMORY_NUDGE_TYPES``) are suppressed
|
||||||
whenever the persona's envelope hides the memory tool — the lever
|
whenever the persona's envelope hides the memory tool — the lever
|
||||||
being off OR a visibility set that omits ``memory`` — because their
|
being off OR an allowlist that hides ``memory`` (a tool_search
|
||||||
copy directs the model at that tool. Behavioural nudges (repeat,
|
expansion that re-adds it re-enables them) — because their copy
|
||||||
compaction, idle children, watches) stay on the config gate only.
|
directs the model at that tool. Every other nudge type passes
|
||||||
|
straight through to the config gate.
|
||||||
"""
|
"""
|
||||||
if not self._mem_cfg.nudges:
|
if not self._mem_cfg.nudges:
|
||||||
return False
|
return False
|
||||||
@@ -3309,8 +3371,7 @@ class ChatSession:
|
|||||||
kind=self._kind,
|
kind=self._kind,
|
||||||
# Persona lever 1: replaces ONLY the BASE module. ENV /
|
# Persona lever 1: replaces ONLY the BASE module. ENV /
|
||||||
# CONTEXT / TOOLS / POLICIES keep composing, so mandatory
|
# CONTEXT / TOOLS / POLICIES keep composing, so mandatory
|
||||||
# prompt policies ride on top of every persona — unlike the
|
# prompt policies ride on top of every persona.
|
||||||
# removed /creative fork, which bypassed composition entirely.
|
|
||||||
base_override=self._persona_prompt or None,
|
base_override=self._persona_prompt or None,
|
||||||
)
|
)
|
||||||
dev_parts = [composed]
|
dev_parts = [composed]
|
||||||
@@ -3354,8 +3415,12 @@ class ChatSession:
|
|||||||
"\n\nAdditional tools are available via tool_search. "
|
"\n\nAdditional tools are available via tool_search. "
|
||||||
"Use it when you need a capability not in your current tool set."
|
"Use it when you need a capability not in your current tool set."
|
||||||
)
|
)
|
||||||
# MCP resource catalog (lets the model know what's available for read_resource)
|
# MCP resource catalog (lets the model know what's available for
|
||||||
if self._mcp_client:
|
# read_resource). Gated on the tool's visibility, not just the
|
||||||
|
# client: a persona allowlist that hides read_resource must drop
|
||||||
|
# the catalog AND its call instruction — same rule as the memory
|
||||||
|
# advisory — or the prompt instructs calls the wire hides.
|
||||||
|
if self._mcp_client and self._persona_tool_visible("read_resource"):
|
||||||
# Per-user merge: pool entries for the effective user (acting
|
# Per-user merge: pool entries for the effective user (acting
|
||||||
# user on shared workstreams, owner otherwise) are included;
|
# user on shared workstreams, owner otherwise) are included;
|
||||||
# other users' pool resources are not.
|
# other users' pool resources are not.
|
||||||
@@ -3382,8 +3447,9 @@ class ChatSession:
|
|||||||
lines.append("</mcp-resources>")
|
lines.append("</mcp-resources>")
|
||||||
lines.append("Use read_resource(uri='...') to access the resources listed above.")
|
lines.append("Use read_resource(uri='...') to access the resources listed above.")
|
||||||
dev_parts.append("\n".join(lines))
|
dev_parts.append("\n".join(lines))
|
||||||
# MCP prompt catalog (lets the model know what's available for use_prompt)
|
# MCP prompt catalog (lets the model know what's available for
|
||||||
if self._mcp_client:
|
# use_prompt) — visibility-gated like the resource catalog above.
|
||||||
|
if self._mcp_client and self._persona_tool_visible("use_prompt"):
|
||||||
# Per-user merge: pool entries for the effective user (acting
|
# Per-user merge: pool entries for the effective user (acting
|
||||||
# user on shared workstreams, owner otherwise) are included;
|
# user on shared workstreams, owner otherwise) are included;
|
||||||
# other users' pool prompts are not.
|
# other users' pool prompts are not.
|
||||||
@@ -4467,7 +4533,7 @@ class ChatSession:
|
|||||||
Persona gating (levers 2+4) runs LAST so the wire never advertises
|
Persona gating (levers 2+4) runs LAST so the wire never advertises
|
||||||
a tool the visibility set hides — whatever branch produced the list.
|
a tool the visibility set hides — whatever branch produced the list.
|
||||||
With a persona visibility set, tool search always runs in
|
With a persona visibility set, tool search always runs in
|
||||||
client-side mode (see ``_get_capabilities`` note below): native
|
client-side mode (see ``_get_deferred_names``): native
|
||||||
defer_loading would strip deferred tools here before the provider
|
defer_loading would strip deferred tools here before the provider
|
||||||
could offer them for discovery.
|
could offer them for discovery.
|
||||||
"""
|
"""
|
||||||
@@ -7480,8 +7546,8 @@ class ChatSession:
|
|||||||
# skill_data dict (or ``None``), not the raw string the
|
# skill_data dict (or ``None``), not the raw string the
|
||||||
# LLM passed. Mirror the ``spawn_workstream`` projection
|
# LLM passed. Mirror the ``spawn_workstream`` projection
|
||||||
# so heuristic ``arg_pattern`` rules can match on skill
|
# so heuristic ``arg_pattern`` rules can match on skill
|
||||||
# name and the judge / audit row sees which persona and
|
# name and the judge / audit row sees which skill was
|
||||||
# model override were selected.
|
# selected.
|
||||||
skill_dict = it.get("skill") or {}
|
skill_dict = it.get("skill") or {}
|
||||||
it["func_args"] = {
|
it["func_args"] = {
|
||||||
"prompt": honest_truncate(it.get("prompt") or "", arg_budget),
|
"prompt": honest_truncate(it.get("prompt") or "", arg_budget),
|
||||||
@@ -7495,6 +7561,7 @@ class ChatSession:
|
|||||||
elif name == "spawn_workstream":
|
elif name == "spawn_workstream":
|
||||||
it["func_args"] = {
|
it["func_args"] = {
|
||||||
"skill": it.get("skill", ""),
|
"skill": it.get("skill", ""),
|
||||||
|
"persona": it.get("persona", ""),
|
||||||
"initial_message": honest_truncate(it.get("initial_message") or "", arg_budget),
|
"initial_message": honest_truncate(it.get("initial_message") or "", arg_budget),
|
||||||
"target_node": it.get("target_node", ""),
|
"target_node": it.get("target_node", ""),
|
||||||
"name": it.get("name", ""),
|
"name": it.get("name", ""),
|
||||||
@@ -7510,7 +7577,9 @@ class ChatSession:
|
|||||||
# ``initial_message`` is honestly truncated to its share.
|
# ``initial_message`` is honestly truncated to its share.
|
||||||
# ``name`` (cosmetic) and ``model`` (registry alias) skipped
|
# ``name`` (cosmetic) and ``model`` (registry alias) skipped
|
||||||
# to keep the payload lean; risk-relevant fields are skill,
|
# to keep the payload lean; risk-relevant fields are skill,
|
||||||
# initial_message, target_node.
|
# persona, initial_message, target_node — a persona swaps the
|
||||||
|
# child's entire base prompt and tool envelope, so the judge
|
||||||
|
# must see it like the human approval header does.
|
||||||
children = it.get("children") or []
|
children = it.get("children") or []
|
||||||
per_child = max(arg_budget // max(len(children), 1), 0)
|
per_child = max(arg_budget // max(len(children), 1), 0)
|
||||||
it["func_args"] = {
|
it["func_args"] = {
|
||||||
@@ -7518,6 +7587,7 @@ class ChatSession:
|
|||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"skill": c.get("skill", "") if isinstance(c, dict) else "",
|
"skill": c.get("skill", "") if isinstance(c, dict) else "",
|
||||||
|
"persona": c.get("persona", "") if isinstance(c, dict) else "",
|
||||||
"initial_message": (
|
"initial_message": (
|
||||||
honest_truncate(c.get("initial_message") or "", per_child)
|
honest_truncate(c.get("initial_message") or "", per_child)
|
||||||
if isinstance(c, dict)
|
if isinstance(c, dict)
|
||||||
@@ -9302,7 +9372,15 @@ class ChatSession:
|
|||||||
results = self._tool_search.search(query)
|
results = self._tool_search.search(query)
|
||||||
# Expand discovered tools into the visible set
|
# Expand discovered tools into the visible set
|
||||||
names = [t.get("function", {}).get("name", "") for t in results]
|
names = [t.get("function", {}).get("name", "") for t in results]
|
||||||
self._tool_search.expand_visible(names)
|
newly_added = self._tool_search.expand_visible(names)
|
||||||
|
if newly_added and self._persona_tools is not None:
|
||||||
|
# Under a soft persona set the prompt was composed against the
|
||||||
|
# pre-expansion visible names, so tool-gated policy segments
|
||||||
|
# for the just-expanded tools were dropped — recompose so the
|
||||||
|
# operator's guidance lands with the tool. The wire tools
|
||||||
|
# array changes on expansion anyway (client-side mode), so the
|
||||||
|
# prefix-cache invalidation is already being paid.
|
||||||
|
self._init_system_messages()
|
||||||
output = self._tool_search.format_search_results(results)
|
output = self._tool_search.format_search_results(results)
|
||||||
return item["call_id"], output
|
return item["call_id"], output
|
||||||
|
|
||||||
@@ -10045,7 +10123,7 @@ class ChatSession:
|
|||||||
# Reset so the model gets a clean slate after the warning.
|
# Reset so the model gets a clean slate after the warning.
|
||||||
# If it repeats again, a new warning fires.
|
# If it repeats again, a new warning fires.
|
||||||
self._repeat_detector.clear()
|
self._repeat_detector.clear()
|
||||||
if self._mem_cfg.nudges and should_nudge(
|
if self._nudges_enabled("repeat") and should_nudge(
|
||||||
"repeat",
|
"repeat",
|
||||||
self._metacog_state,
|
self._metacog_state,
|
||||||
message_count=len(self.messages),
|
message_count=len(self.messages),
|
||||||
@@ -10159,7 +10237,12 @@ class ChatSession:
|
|||||||
model = (args.get("model") or "").strip()
|
model = (args.get("model") or "").strip()
|
||||||
target_node = (args.get("target_node") or "").strip()
|
target_node = (args.get("target_node") or "").strip()
|
||||||
project = (args.get("project") or "").strip()
|
project = (args.get("project") or "").strip()
|
||||||
persona = (args.get("persona") or "").strip()
|
# Flatten whitespace and cap before the tag reaches the trusted
|
||||||
|
# approval-header chrome — the value is model-authored and, when
|
||||||
|
# storage is down, reaches the header unvalidated. Real slugs are
|
||||||
|
# ≤64 chars with no whitespace, so this only reshapes invalid names
|
||||||
|
# (which then fail resolution showing the sanitized string).
|
||||||
|
persona = " ".join((args.get("persona") or "").split())[:64]
|
||||||
if persona:
|
if persona:
|
||||||
persona_err = self._validate_child_persona(persona)
|
persona_err = self._validate_child_persona(persona)
|
||||||
if persona_err:
|
if persona_err:
|
||||||
@@ -10207,8 +10290,6 @@ class ChatSession:
|
|||||||
model can react to. Best-effort: a storage blip defers the
|
model can react to. Best-effort: a storage blip defers the
|
||||||
verdict to the create handler rather than blocking the spawn.
|
verdict to the create handler rather than blocking the spawn.
|
||||||
"""
|
"""
|
||||||
from turnstone.core.personas import resolve_persona_for_kind
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
storage = get_storage()
|
storage = get_storage()
|
||||||
if storage is None:
|
if storage is None:
|
||||||
@@ -14069,18 +14150,18 @@ class ChatSession:
|
|||||||
"ws_id": self._ws_id,
|
"ws_id": self._ws_id,
|
||||||
"node_id": self._node_id or "",
|
"node_id": self._node_id or "",
|
||||||
}
|
}
|
||||||
persona = _render_template(skill_data["content"], context)
|
skill_identity = _render_template(skill_data["content"], context)
|
||||||
if len(persona) > _MAX_SKILL_CONTENT:
|
if len(skill_identity) > _MAX_SKILL_CONTENT:
|
||||||
log.warning(
|
log.warning(
|
||||||
"skill_content.truncated",
|
"skill_content.truncated",
|
||||||
length=len(persona),
|
length=len(skill_identity),
|
||||||
agent="task",
|
agent="task",
|
||||||
skill=skill_data.get("name", ""),
|
skill=skill_data.get("name", ""),
|
||||||
)
|
)
|
||||||
persona = persona[:_MAX_SKILL_CONTENT]
|
skill_identity = skill_identity[:_MAX_SKILL_CONTENT]
|
||||||
else:
|
else:
|
||||||
persona = self._TASK_DEFAULT_IDENTITY
|
skill_identity = self._TASK_DEFAULT_IDENTITY
|
||||||
identity = persona + "\n\n" + self._TASK_OPERATING_GUIDANCE
|
identity = skill_identity + "\n\n" + self._TASK_OPERATING_GUIDANCE
|
||||||
# Task agent gets the base system prompt (tool patterns) merged
|
# Task agent gets the base system prompt (tool patterns) merged
|
||||||
# with its own identity in a single system message. No conversation
|
# with its own identity in a single system message. No conversation
|
||||||
# history — it's an autonomous sub-agent. Merged to avoid
|
# history — it's an autonomous sub-agent. Merged to avoid
|
||||||
@@ -15342,15 +15423,24 @@ class ChatSession:
|
|||||||
self.ui.on_info(f"Workstream not found: {arg.strip()}")
|
self.ui.on_info(f"Workstream not found: {arg.strip()}")
|
||||||
elif target_id == self._ws_id:
|
elif target_id == self._ws_id:
|
||||||
self.ui.on_info("Already in that workstream.")
|
self.ui.on_info("Already in that workstream.")
|
||||||
elif self.resume(target_id):
|
|
||||||
self.ui.on_info(
|
|
||||||
f"Resumed {bold(target_id)} ({len(self.messages)} messages loaded)"
|
|
||||||
)
|
|
||||||
name = get_workstream_display_name(target_id)
|
|
||||||
if name:
|
|
||||||
self.ui.on_rename(name)
|
|
||||||
else:
|
else:
|
||||||
self.ui.on_info(f"Workstream {arg.strip()} has no messages.")
|
try:
|
||||||
|
resumed: bool | None = self.resume(target_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
# Corrupt stamp or MCP-lever refusal: resume parses
|
||||||
|
# before mutating, so this session is untouched —
|
||||||
|
# report and stay on the current workstream.
|
||||||
|
self.ui.on_error(f"Cannot resume {target_id}: {exc}")
|
||||||
|
resumed = None
|
||||||
|
if resumed:
|
||||||
|
self.ui.on_info(
|
||||||
|
f"Resumed {bold(target_id)} ({len(self.messages)} messages loaded)"
|
||||||
|
)
|
||||||
|
name = get_workstream_display_name(target_id)
|
||||||
|
if name:
|
||||||
|
self.ui.on_rename(name)
|
||||||
|
elif resumed is False:
|
||||||
|
self.ui.on_info(f"Workstream {arg.strip()} has no messages.")
|
||||||
|
|
||||||
elif cmd == "/name":
|
elif cmd == "/name":
|
||||||
if not arg:
|
if not arg:
|
||||||
@@ -15487,11 +15577,17 @@ class ChatSession:
|
|||||||
self.ui.on_info("Compaction cancelled.")
|
self.ui.on_info("Compaction cancelled.")
|
||||||
|
|
||||||
elif cmd == "/creative":
|
elif cmd == "/creative":
|
||||||
# Removed in 1.7 — the writer persona replaces it (issue #683).
|
# Recognized but decommissioned: print a live migration pointer
|
||||||
self.ui.on_info(
|
# instead of an "unknown command" dead end. The 'writer' seed
|
||||||
"/creative was removed. Start a session with the 'writer' "
|
# is archivable, so resolve it before advertising it.
|
||||||
"persona instead: turnstone --persona writer"
|
replacement = "a writing persona (see the personas list)"
|
||||||
)
|
try:
|
||||||
|
row, _ = resolve_persona_for_kind(get_storage(), "writer", "interactive")
|
||||||
|
if row is not None:
|
||||||
|
replacement = "the 'writer' persona: turnstone --persona writer"
|
||||||
|
except Exception:
|
||||||
|
log.debug("creative_redirect.writer_resolve_failed", exc_info=True)
|
||||||
|
self.ui.on_info(f"/creative was removed. Start a session with {replacement}.")
|
||||||
|
|
||||||
elif cmd == "/debug":
|
elif cmd == "/debug":
|
||||||
self.debug = not self.debug
|
self.debug = not self.debug
|
||||||
|
|||||||
@@ -2490,9 +2490,7 @@ def make_create_handler(
|
|||||||
# engineer/orchestrator defaults. Resume skips resolution:
|
# engineer/orchestrator defaults. Resume skips resolution:
|
||||||
# the resumed session restores its own stamp from config.
|
# the resumed session restores its own stamp from config.
|
||||||
body_persona_raw = body.get("persona") or ""
|
body_persona_raw = body.get("persona") or ""
|
||||||
body_persona = (
|
body_persona = body_persona_raw.strip() if isinstance(body_persona_raw, str) else ""
|
||||||
body_persona_raw.strip() if isinstance(body_persona_raw, str) else ""
|
|
||||||
)
|
|
||||||
persona_snapshot = None
|
persona_snapshot = None
|
||||||
if isinstance(resume_ws_id_raw, str) and resume_ws_id_raw:
|
if isinstance(resume_ws_id_raw, str) and resume_ws_id_raw:
|
||||||
# Fork-resume adopts the SOURCE workstream's stamp, resolved
|
# Fork-resume adopts the SOURCE workstream's stamp, resolved
|
||||||
@@ -2511,10 +2509,7 @@ def make_create_handler(
|
|||||||
if _st is not None and resume_target:
|
if _st is not None and resume_target:
|
||||||
try:
|
try:
|
||||||
persona_snapshot = snapshot_from_config(
|
persona_snapshot = snapshot_from_config(
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(_st.load_workstream_config, resume_target) or {}
|
||||||
_st.load_workstream_config, resume_target
|
|
||||||
)
|
|
||||||
or {}
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -2539,19 +2534,24 @@ def make_create_handler(
|
|||||||
if persona_err:
|
if persona_err:
|
||||||
return JSONResponse({"error": persona_err}, status_code=400)
|
return JSONResponse({"error": persona_err}, status_code=400)
|
||||||
else:
|
else:
|
||||||
# Default-persona lookup is best-effort: a storage blip
|
# No explicit persona: stamp the kind's default. A clean
|
||||||
# here degrades to an unstamped (legacy) create, which
|
# ``None`` (no default configured — pre-seed DB) creates
|
||||||
# is behavior-identical to the shipped defaults —
|
# unstamped legacy, but a FAILED lookup must not: the
|
||||||
# never a reason to fail the create.
|
# operator may have promoted a restricted persona to
|
||||||
try:
|
# default, and degrading to the stock envelope on a
|
||||||
_st = _get_storage()
|
# storage blip would silently widen it.
|
||||||
if _st is not None:
|
_st = _get_storage()
|
||||||
|
if _st is not None:
|
||||||
|
try:
|
||||||
persona_row = await asyncio.to_thread(
|
persona_row = await asyncio.to_thread(
|
||||||
_st.get_default_persona, mgr.kind.value
|
_st.get_default_persona, mgr.kind.value
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("ws.create.default_persona_lookup_failed", exc_info=True)
|
log.warning("ws.create.default_persona_lookup_failed", exc_info=True)
|
||||||
persona_row = None
|
return JSONResponse(
|
||||||
|
{"error": "persona resolution unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
if persona_row is not None:
|
if persona_row is not None:
|
||||||
persona_snapshot = snapshot_from_persona(persona_row)
|
persona_snapshot = snapshot_from_persona(persona_row)
|
||||||
|
|
||||||
@@ -2749,6 +2749,10 @@ def make_list_handler(cfg: SessionEndpointConfig) -> Handler:
|
|||||||
for ws in wss:
|
for ws in wss:
|
||||||
raw_pid = getattr(ws, "project_id", "")
|
raw_pid = getattr(ws, "project_id", "")
|
||||||
project_id = raw_pid if isinstance(raw_pid, str) else ""
|
project_id = raw_pid if isinstance(raw_pid, str) else ""
|
||||||
|
# Same guarded read as project_id — test doubles and older
|
||||||
|
# node payloads may lack the attribute.
|
||||||
|
raw_persona = getattr(ws, "persona", "")
|
||||||
|
persona = raw_persona if isinstance(raw_persona, str) else ""
|
||||||
# Private-project tenancy — drop rows the requester may
|
# Private-project tenancy — drop rows the requester may
|
||||||
# not see (same predicate as the saved list).
|
# not see (same predicate as the saved list).
|
||||||
if not visibility.ws_visible(project_id, ws_owner=ws.user_id or ""):
|
if not visibility.ws_visible(project_id, ws_owner=ws.user_id or ""):
|
||||||
@@ -2763,6 +2767,7 @@ def make_list_handler(cfg: SessionEndpointConfig) -> Handler:
|
|||||||
"parent_ws_id": ws.parent_ws_id,
|
"parent_ws_id": ws.parent_ws_id,
|
||||||
"user_id": ws.user_id,
|
"user_id": ws.user_id,
|
||||||
"project_id": project_id or None,
|
"project_id": project_id or None,
|
||||||
|
"persona": persona or None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ from turnstone.core.storage._utils import (
|
|||||||
from turnstone.core.storage._utils import (
|
from turnstone.core.storage._utils import (
|
||||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||||
)
|
)
|
||||||
|
from turnstone.core.storage._utils import (
|
||||||
|
assert_single_default_persona as _assert_single_default_persona,
|
||||||
|
)
|
||||||
from turnstone.core.storage._utils import (
|
from turnstone.core.storage._utils import (
|
||||||
build_attachments_by_msg as _build_attachments_by_msg,
|
build_attachments_by_msg as _build_attachments_by_msg,
|
||||||
)
|
)
|
||||||
@@ -168,6 +171,9 @@ from turnstone.core.storage._utils import (
|
|||||||
from turnstone.core.storage._utils import (
|
from turnstone.core.storage._utils import (
|
||||||
split_perms as _split_perms,
|
split_perms as _split_perms,
|
||||||
)
|
)
|
||||||
|
from turnstone.core.storage._utils import (
|
||||||
|
validate_and_clear_default_persona as _validate_and_clear_default_persona,
|
||||||
|
)
|
||||||
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
@@ -5603,40 +5609,62 @@ class PostgreSQLBackend:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise ValueError(f"persona name already exists: {values['name']}")
|
raise ValueError(f"persona name already exists: {values['name']}")
|
||||||
|
default_kinds = persona.get("applies_to_kinds") or ["interactive"]
|
||||||
if values.get("is_default"):
|
if values.get("is_default"):
|
||||||
self._validate_and_clear_default(
|
# Serialize default promotions cluster-wide: under READ
|
||||||
|
# COMMITTED two concurrent promotions can each miss the
|
||||||
|
# other's uncommitted flag and commit two defaults. The
|
||||||
|
# xact-scoped advisory lock releases on commit/rollback.
|
||||||
|
conn.execute(
|
||||||
|
sa.text("SELECT pg_advisory_xact_lock(hashtext('turnstone_personas_default'))")
|
||||||
|
)
|
||||||
|
_validate_and_clear_default_persona(
|
||||||
conn,
|
conn,
|
||||||
|
personas,
|
||||||
persona_id=values["persona_id"],
|
persona_id=values["persona_id"],
|
||||||
kinds=persona.get("applies_to_kinds") or ["interactive"],
|
kinds=default_kinds,
|
||||||
enabled=persona.get("enabled", True),
|
enabled=persona.get("enabled", True),
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
conn.execute(
|
try:
|
||||||
sa.insert(personas),
|
conn.execute(
|
||||||
{
|
sa.insert(personas),
|
||||||
"persona_id": values["persona_id"],
|
{
|
||||||
"name": values["name"],
|
"persona_id": values["persona_id"],
|
||||||
"display_name": values.get("display_name", ""),
|
"name": values["name"],
|
||||||
"description": values.get("description", ""),
|
"display_name": values.get("display_name", ""),
|
||||||
"base_prompt": values.get("base_prompt"),
|
"description": values.get("description", ""),
|
||||||
"tool_allowlist": values.get("tool_allowlist"),
|
"base_prompt": values.get("base_prompt"),
|
||||||
"mcp_enabled": values.get("mcp_enabled", 1),
|
"tool_allowlist": values.get("tool_allowlist"),
|
||||||
"memory_enabled": values.get("memory_enabled", 1),
|
"mcp_enabled": values.get("mcp_enabled", 1),
|
||||||
"applies_to_kinds": values.get("applies_to_kinds", '["interactive"]'),
|
"memory_enabled": values.get("memory_enabled", 1),
|
||||||
"is_default": values.get("is_default", 0),
|
"applies_to_kinds": values.get("applies_to_kinds", '["interactive"]'),
|
||||||
"enabled": values.get("enabled", 1),
|
"is_default": values.get("is_default", 0),
|
||||||
"org_id": values.get("org_id", ""),
|
"enabled": values.get("enabled", 1),
|
||||||
"created_by": values.get("created_by", ""),
|
"org_id": values.get("org_id", ""),
|
||||||
"created": now,
|
"created_by": values.get("created_by", ""),
|
||||||
"updated": now,
|
"created": now,
|
||||||
},
|
"updated": now,
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
except sa.exc.IntegrityError as exc:
|
||||||
|
# SELECT-then-INSERT loser on unique(name): surface the
|
||||||
|
# same ValueError the pre-check raises so callers map one
|
||||||
|
# error shape (400), not an opaque 500.
|
||||||
|
raise ValueError(f"persona name already exists: {values['name']}") from exc
|
||||||
|
if values.get("is_default"):
|
||||||
|
_assert_single_default_persona(conn, personas, default_kinds[0])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def update_persona(self, persona_id: str, **fields: Any) -> bool:
|
def update_persona(self, persona_id: str, **fields: Any) -> bool:
|
||||||
fields = {k: v for k, v in fields.items() if k in _PERSONA_MUTABLE}
|
fields = {k: v for k, v in fields.items() if k in _PERSONA_MUTABLE}
|
||||||
if not fields:
|
if not fields:
|
||||||
return False
|
return False
|
||||||
|
# Validate/serialize BEFORE the invariant checks so malformed input
|
||||||
|
# (explicit-None kinds, wrong types) surfaces as the serializer's
|
||||||
|
# precise ValueError instead of a TypeError escaping the routes'
|
||||||
|
# 400 mapping as a 500.
|
||||||
|
values = _serialize_persona_fields(fields)
|
||||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -5650,64 +5678,37 @@ class PostgreSQLBackend:
|
|||||||
raise ValueError("the default persona cannot be archived")
|
raise ValueError("the default persona cannot be archived")
|
||||||
if "is_default" in fields and not fields["is_default"]:
|
if "is_default" in fields and not fields["is_default"]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"cannot unset is_default directly; set it on the "
|
"cannot unset is_default directly; set it on the successor persona instead"
|
||||||
"successor persona instead"
|
|
||||||
)
|
)
|
||||||
if "applies_to_kinds" in fields and sorted(
|
if "applies_to_kinds" in fields and sorted(
|
||||||
fields["applies_to_kinds"] or []
|
fields["applies_to_kinds"] or []
|
||||||
) != sorted(current["applies_to_kinds"]):
|
) != sorted(current["applies_to_kinds"]):
|
||||||
raise ValueError("cannot change applies_to_kinds of the default persona")
|
raise ValueError("cannot change applies_to_kinds of the default persona")
|
||||||
if fields.get("is_default") and not current["is_default"]:
|
promote = bool(fields.get("is_default")) and not current["is_default"]
|
||||||
self._validate_and_clear_default(
|
promote_kinds = fields.get("applies_to_kinds", current["applies_to_kinds"])
|
||||||
|
if promote:
|
||||||
|
# Serialize default promotions cluster-wide (see
|
||||||
|
# create_persona for the READ COMMITTED rationale).
|
||||||
|
conn.execute(
|
||||||
|
sa.text("SELECT pg_advisory_xact_lock(hashtext('turnstone_personas_default'))")
|
||||||
|
)
|
||||||
|
_validate_and_clear_default_persona(
|
||||||
conn,
|
conn,
|
||||||
|
personas,
|
||||||
persona_id=persona_id,
|
persona_id=persona_id,
|
||||||
kinds=fields.get("applies_to_kinds", current["applies_to_kinds"]),
|
kinds=promote_kinds,
|
||||||
enabled=fields.get("enabled", current["enabled"]),
|
enabled=fields.get("enabled", current["enabled"]),
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
values = _serialize_persona_fields(fields)
|
|
||||||
values["updated"] = now
|
values["updated"] = now
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.update(personas).where(personas.c.persona_id == persona_id).values(**values)
|
sa.update(personas).where(personas.c.persona_id == persona_id).values(**values)
|
||||||
)
|
)
|
||||||
|
if promote:
|
||||||
|
_assert_single_default_persona(conn, personas, promote_kinds[0])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_and_clear_default(
|
|
||||||
conn: sa.engine.Connection,
|
|
||||||
*,
|
|
||||||
persona_id: str,
|
|
||||||
kinds: list[str],
|
|
||||||
enabled: Any,
|
|
||||||
now: str,
|
|
||||||
) -> None:
|
|
||||||
"""Enforce the default-persona invariants and demote the previous
|
|
||||||
holder, inside the caller's transaction.
|
|
||||||
|
|
||||||
Exactly one default per kind: a default must be single-kind (a
|
|
||||||
two-kind default would get orphan-cleared when either kind's crown
|
|
||||||
moves) and enabled; the incumbent default for that kind loses the
|
|
||||||
flag in the same transaction.
|
|
||||||
"""
|
|
||||||
if len(kinds) != 1:
|
|
||||||
raise ValueError("a default persona must apply to exactly one kind")
|
|
||||||
if not enabled:
|
|
||||||
raise ValueError("a disabled persona cannot be the default")
|
|
||||||
kind = kinds[0]
|
|
||||||
others = conn.execute(
|
|
||||||
sa.select(personas.c.persona_id, personas.c.applies_to_kinds).where(
|
|
||||||
sa.and_(personas.c.is_default == 1, personas.c.persona_id != persona_id)
|
|
||||||
)
|
|
||||||
).fetchall()
|
|
||||||
for oid, okinds_raw in others:
|
|
||||||
if kind in json.loads(okinds_raw or "[]"):
|
|
||||||
conn.execute(
|
|
||||||
sa.update(personas)
|
|
||||||
.where(personas.c.persona_id == oid)
|
|
||||||
.values(is_default=0, updated=now)
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Prompt policies -------------------------------------------------------
|
# -- Prompt policies -------------------------------------------------------
|
||||||
|
|
||||||
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ from turnstone.core.storage._utils import (
|
|||||||
from turnstone.core.storage._utils import (
|
from turnstone.core.storage._utils import (
|
||||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||||
)
|
)
|
||||||
|
from turnstone.core.storage._utils import (
|
||||||
|
assert_single_default_persona as _assert_single_default_persona,
|
||||||
|
)
|
||||||
from turnstone.core.storage._utils import (
|
from turnstone.core.storage._utils import (
|
||||||
build_attachments_by_msg as _build_attachments_by_msg,
|
build_attachments_by_msg as _build_attachments_by_msg,
|
||||||
)
|
)
|
||||||
@@ -168,6 +171,9 @@ from turnstone.core.storage._utils import (
|
|||||||
from turnstone.core.storage._utils import (
|
from turnstone.core.storage._utils import (
|
||||||
split_perms as _split_perms,
|
split_perms as _split_perms,
|
||||||
)
|
)
|
||||||
|
from turnstone.core.storage._utils import (
|
||||||
|
validate_and_clear_default_persona as _validate_and_clear_default_persona,
|
||||||
|
)
|
||||||
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
@@ -5766,40 +5772,55 @@ class SQLiteBackend:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise ValueError(f"persona name already exists: {values['name']}")
|
raise ValueError(f"persona name already exists: {values['name']}")
|
||||||
|
default_kinds = persona.get("applies_to_kinds") or ["interactive"]
|
||||||
if values.get("is_default"):
|
if values.get("is_default"):
|
||||||
self._validate_and_clear_default(
|
_validate_and_clear_default_persona(
|
||||||
conn,
|
conn,
|
||||||
|
personas,
|
||||||
persona_id=values["persona_id"],
|
persona_id=values["persona_id"],
|
||||||
kinds=persona.get("applies_to_kinds") or ["interactive"],
|
kinds=default_kinds,
|
||||||
enabled=persona.get("enabled", True),
|
enabled=persona.get("enabled", True),
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
conn.execute(
|
try:
|
||||||
sa.insert(personas),
|
conn.execute(
|
||||||
{
|
sa.insert(personas),
|
||||||
"persona_id": values["persona_id"],
|
{
|
||||||
"name": values["name"],
|
"persona_id": values["persona_id"],
|
||||||
"display_name": values.get("display_name", ""),
|
"name": values["name"],
|
||||||
"description": values.get("description", ""),
|
"display_name": values.get("display_name", ""),
|
||||||
"base_prompt": values.get("base_prompt"),
|
"description": values.get("description", ""),
|
||||||
"tool_allowlist": values.get("tool_allowlist"),
|
"base_prompt": values.get("base_prompt"),
|
||||||
"mcp_enabled": values.get("mcp_enabled", 1),
|
"tool_allowlist": values.get("tool_allowlist"),
|
||||||
"memory_enabled": values.get("memory_enabled", 1),
|
"mcp_enabled": values.get("mcp_enabled", 1),
|
||||||
"applies_to_kinds": values.get("applies_to_kinds", '["interactive"]'),
|
"memory_enabled": values.get("memory_enabled", 1),
|
||||||
"is_default": values.get("is_default", 0),
|
"applies_to_kinds": values.get("applies_to_kinds", '["interactive"]'),
|
||||||
"enabled": values.get("enabled", 1),
|
"is_default": values.get("is_default", 0),
|
||||||
"org_id": values.get("org_id", ""),
|
"enabled": values.get("enabled", 1),
|
||||||
"created_by": values.get("created_by", ""),
|
"org_id": values.get("org_id", ""),
|
||||||
"created": now,
|
"created_by": values.get("created_by", ""),
|
||||||
"updated": now,
|
"created": now,
|
||||||
},
|
"updated": now,
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
except sa.exc.IntegrityError as exc:
|
||||||
|
# SELECT-then-INSERT loser on unique(name): surface the
|
||||||
|
# same ValueError the pre-check raises so callers map one
|
||||||
|
# error shape (400), not an opaque 500.
|
||||||
|
raise ValueError(f"persona name already exists: {values['name']}") from exc
|
||||||
|
if values.get("is_default"):
|
||||||
|
_assert_single_default_persona(conn, personas, default_kinds[0])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def update_persona(self, persona_id: str, **fields: Any) -> bool:
|
def update_persona(self, persona_id: str, **fields: Any) -> bool:
|
||||||
fields = {k: v for k, v in fields.items() if k in _PERSONA_MUTABLE}
|
fields = {k: v for k, v in fields.items() if k in _PERSONA_MUTABLE}
|
||||||
if not fields:
|
if not fields:
|
||||||
return False
|
return False
|
||||||
|
# Validate/serialize BEFORE the invariant checks so malformed input
|
||||||
|
# (explicit-None kinds, wrong types) surfaces as the serializer's
|
||||||
|
# precise ValueError instead of a TypeError escaping the routes'
|
||||||
|
# 400 mapping as a 500.
|
||||||
|
values = _serialize_persona_fields(fields)
|
||||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -5813,64 +5834,32 @@ class SQLiteBackend:
|
|||||||
raise ValueError("the default persona cannot be archived")
|
raise ValueError("the default persona cannot be archived")
|
||||||
if "is_default" in fields and not fields["is_default"]:
|
if "is_default" in fields and not fields["is_default"]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"cannot unset is_default directly; set it on the "
|
"cannot unset is_default directly; set it on the successor persona instead"
|
||||||
"successor persona instead"
|
|
||||||
)
|
)
|
||||||
if "applies_to_kinds" in fields and sorted(
|
if "applies_to_kinds" in fields and sorted(
|
||||||
fields["applies_to_kinds"] or []
|
fields["applies_to_kinds"] or []
|
||||||
) != sorted(current["applies_to_kinds"]):
|
) != sorted(current["applies_to_kinds"]):
|
||||||
raise ValueError("cannot change applies_to_kinds of the default persona")
|
raise ValueError("cannot change applies_to_kinds of the default persona")
|
||||||
if fields.get("is_default") and not current["is_default"]:
|
promote = bool(fields.get("is_default")) and not current["is_default"]
|
||||||
self._validate_and_clear_default(
|
promote_kinds = fields.get("applies_to_kinds", current["applies_to_kinds"])
|
||||||
|
if promote:
|
||||||
|
_validate_and_clear_default_persona(
|
||||||
conn,
|
conn,
|
||||||
|
personas,
|
||||||
persona_id=persona_id,
|
persona_id=persona_id,
|
||||||
kinds=fields.get("applies_to_kinds", current["applies_to_kinds"]),
|
kinds=promote_kinds,
|
||||||
enabled=fields.get("enabled", current["enabled"]),
|
enabled=fields.get("enabled", current["enabled"]),
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
values = _serialize_persona_fields(fields)
|
|
||||||
values["updated"] = now
|
values["updated"] = now
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.update(personas).where(personas.c.persona_id == persona_id).values(**values)
|
sa.update(personas).where(personas.c.persona_id == persona_id).values(**values)
|
||||||
)
|
)
|
||||||
|
if promote:
|
||||||
|
_assert_single_default_persona(conn, personas, promote_kinds[0])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_and_clear_default(
|
|
||||||
conn: sa.engine.Connection,
|
|
||||||
*,
|
|
||||||
persona_id: str,
|
|
||||||
kinds: list[str],
|
|
||||||
enabled: Any,
|
|
||||||
now: str,
|
|
||||||
) -> None:
|
|
||||||
"""Enforce the default-persona invariants and demote the previous
|
|
||||||
holder, inside the caller's transaction.
|
|
||||||
|
|
||||||
Exactly one default per kind: a default must be single-kind (a
|
|
||||||
two-kind default would get orphan-cleared when either kind's crown
|
|
||||||
moves) and enabled; the incumbent default for that kind loses the
|
|
||||||
flag in the same transaction.
|
|
||||||
"""
|
|
||||||
if len(kinds) != 1:
|
|
||||||
raise ValueError("a default persona must apply to exactly one kind")
|
|
||||||
if not enabled:
|
|
||||||
raise ValueError("a disabled persona cannot be the default")
|
|
||||||
kind = kinds[0]
|
|
||||||
others = conn.execute(
|
|
||||||
sa.select(personas.c.persona_id, personas.c.applies_to_kinds).where(
|
|
||||||
sa.and_(personas.c.is_default == 1, personas.c.persona_id != persona_id)
|
|
||||||
)
|
|
||||||
).fetchall()
|
|
||||||
for oid, okinds_raw in others:
|
|
||||||
if kind in json.loads(okinds_raw or "[]"):
|
|
||||||
conn.execute(
|
|
||||||
sa.update(personas)
|
|
||||||
.where(personas.c.persona_id == oid)
|
|
||||||
.values(is_default=0, updated=now)
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Prompt policies -------------------------------------------------------
|
# -- Prompt policies -------------------------------------------------------
|
||||||
|
|
||||||
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -664,23 +664,64 @@ PERSONA_KINDS = frozenset({"interactive", "coordinator"})
|
|||||||
def persona_row_to_dict(row: Any) -> dict[str, Any]:
|
def persona_row_to_dict(row: Any) -> dict[str, Any]:
|
||||||
"""Convert a personas row to the Python-typed dict shape the Protocol
|
"""Convert a personas row to the Python-typed dict shape the Protocol
|
||||||
documents: JSON columns parsed, 0/1 columns as bool. ``tool_allowlist``
|
documents: JSON columns parsed, 0/1 columns as bool. ``tool_allowlist``
|
||||||
keeps its tri-state — None (unrestricted) vs [] (hard empty) vs [names]."""
|
keeps its tri-state — None (unrestricted) vs [] (hard empty) vs [names].
|
||||||
|
|
||||||
|
Raises ValueError on a corrupt row: a malformed allowlist or kinds
|
||||||
|
column must fail loudly (mirroring ``snapshot_from_config``), never
|
||||||
|
decode into a garbage envelope or mask a broken invariant.
|
||||||
|
"""
|
||||||
d = row_to_dict(row, "mcp_enabled", "memory_enabled", "is_default", "enabled")
|
d = row_to_dict(row, "mcp_enabled", "memory_enabled", "is_default", "enabled")
|
||||||
|
|
||||||
|
def _load_json(column: str, raw_value: Any) -> Any:
|
||||||
|
# Re-raise parser errors with the persona named — a bare
|
||||||
|
# JSONDecodeError message doesn't say WHICH row is corrupt.
|
||||||
|
try:
|
||||||
|
return json.loads(raw_value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"corrupt {column} on persona {d.get('persona_id')!r}: {exc}") from exc
|
||||||
|
|
||||||
raw = d.get("tool_allowlist")
|
raw = d.get("tool_allowlist")
|
||||||
d["tool_allowlist"] = None if raw is None else json.loads(raw)
|
if raw is None:
|
||||||
d["applies_to_kinds"] = json.loads(d.get("applies_to_kinds") or '["interactive"]')
|
d["tool_allowlist"] = None
|
||||||
|
else:
|
||||||
|
tools = _load_json("tool_allowlist", raw)
|
||||||
|
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
||||||
|
raise ValueError(f"corrupt tool_allowlist on persona {d.get('persona_id')!r}")
|
||||||
|
d["tool_allowlist"] = tools
|
||||||
|
kinds_raw = d.get("applies_to_kinds")
|
||||||
|
kinds = _load_json("applies_to_kinds", kinds_raw) if kinds_raw else None
|
||||||
|
if not isinstance(kinds, list) or not kinds or not all(isinstance(k, str) for k in kinds):
|
||||||
|
raise ValueError(f"corrupt applies_to_kinds on persona {d.get('persona_id')!r}")
|
||||||
|
d["applies_to_kinds"] = kinds
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# Storage-layer size bounds for operator-authored persona fields. The
|
||||||
|
# console route truncates its inputs to the same shape, but the storage
|
||||||
|
# edge is the layer every future ingress (SDK-direct, admin CLI) inherits —
|
||||||
|
# reject rather than silently truncate here.
|
||||||
|
PERSONA_FIELD_CAPS: dict[str, int] = {
|
||||||
|
"display_name": 128,
|
||||||
|
"description": 1024,
|
||||||
|
"base_prompt": 32768,
|
||||||
|
}
|
||||||
|
PERSONA_ALLOWLIST_MAX_ENTRIES = 512
|
||||||
|
PERSONA_ALLOWLIST_MAX_NAME_LEN = 256
|
||||||
|
|
||||||
|
|
||||||
def serialize_persona_fields(fields: dict[str, Any]) -> dict[str, Any]:
|
def serialize_persona_fields(fields: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Validate + serialize Python-typed persona fields to column values.
|
"""Validate + serialize Python-typed persona fields to column values.
|
||||||
|
|
||||||
Shared by both backends so the tri-state allowlist encoding and the
|
Shared by both backends so the tri-state allowlist encoding, the kinds
|
||||||
kinds validation can't drift between them. Raises ValueError on
|
validation, and the size bounds can't drift between them. Raises
|
||||||
malformed input; unknown keys pass through (callers filter to the
|
ValueError on malformed or oversized input; unknown keys pass through
|
||||||
mutable set first where that matters).
|
(callers filter to the mutable set first where that matters).
|
||||||
"""
|
"""
|
||||||
out = dict(fields)
|
out = dict(fields)
|
||||||
|
for key, cap in PERSONA_FIELD_CAPS.items():
|
||||||
|
val = out.get(key)
|
||||||
|
if val is not None and key in out and len(str(val)) > cap:
|
||||||
|
raise ValueError(f"{key} exceeds {cap} characters")
|
||||||
if "applies_to_kinds" in out:
|
if "applies_to_kinds" in out:
|
||||||
kinds = out["applies_to_kinds"]
|
kinds = out["applies_to_kinds"]
|
||||||
if not isinstance(kinds, list) or not kinds or not set(kinds) <= PERSONA_KINDS:
|
if not isinstance(kinds, list) or not kinds or not set(kinds) <= PERSONA_KINDS:
|
||||||
@@ -692,11 +733,79 @@ def serialize_persona_fields(fields: dict[str, Any]) -> dict[str, Any]:
|
|||||||
tools = out["tool_allowlist"]
|
tools = out["tool_allowlist"]
|
||||||
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
||||||
raise ValueError("tool_allowlist must be None or a list of tool names")
|
raise ValueError("tool_allowlist must be None or a list of tool names")
|
||||||
|
if len(tools) > PERSONA_ALLOWLIST_MAX_ENTRIES:
|
||||||
|
raise ValueError(f"tool_allowlist exceeds {PERSONA_ALLOWLIST_MAX_ENTRIES} entries")
|
||||||
|
if any(len(t) > PERSONA_ALLOWLIST_MAX_NAME_LEN for t in tools):
|
||||||
|
raise ValueError(
|
||||||
|
f"tool_allowlist entries are capped at {PERSONA_ALLOWLIST_MAX_NAME_LEN} chars"
|
||||||
|
)
|
||||||
out["tool_allowlist"] = json.dumps(tools)
|
out["tool_allowlist"] = json.dumps(tools)
|
||||||
for key in ("mcp_enabled", "memory_enabled", "is_default", "enabled"):
|
for key in ("mcp_enabled", "memory_enabled", "is_default", "enabled"):
|
||||||
if key in out:
|
if key in out:
|
||||||
out[key] = 1 if out[key] else 0
|
out[key] = 1 if out[key] else 0
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_clear_default_persona(
|
||||||
|
conn: Any,
|
||||||
|
personas_table: Any,
|
||||||
|
*,
|
||||||
|
persona_id: str,
|
||||||
|
kinds: list[str],
|
||||||
|
enabled: Any,
|
||||||
|
now: str,
|
||||||
|
) -> None:
|
||||||
|
"""Enforce the default-persona invariants and demote the incumbent,
|
||||||
|
inside the caller's transaction.
|
||||||
|
|
||||||
|
Shared by both backends (fully dialect-neutral) so the invariants —
|
||||||
|
exactly one default per kind, single-kind, enabled — cannot drift.
|
||||||
|
A corrupt incumbent row raises rather than being skipped: silently
|
||||||
|
not-demoting it would commit two defaults, the exact state this
|
||||||
|
helper exists to prevent. Concurrency: the PostgreSQL backend
|
||||||
|
serializes promotions with an advisory xact lock before calling this;
|
||||||
|
``assert_single_default_persona`` runs post-promote as the backstop.
|
||||||
|
"""
|
||||||
|
if not isinstance(kinds, list) or len(kinds) != 1:
|
||||||
|
raise ValueError("a default persona must apply to exactly one kind")
|
||||||
|
if not enabled:
|
||||||
|
raise ValueError("a disabled persona cannot be the default")
|
||||||
|
kind = kinds[0]
|
||||||
|
others = conn.execute(
|
||||||
|
sa.select(personas_table.c.persona_id, personas_table.c.applies_to_kinds).where(
|
||||||
|
sa.and_(
|
||||||
|
personas_table.c.is_default == 1,
|
||||||
|
personas_table.c.persona_id != persona_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
for oid, okinds_raw in others:
|
||||||
|
okinds = json.loads(okinds_raw) if okinds_raw else None
|
||||||
|
if not isinstance(okinds, list):
|
||||||
|
raise ValueError(f"corrupt applies_to_kinds on persona {oid!r}")
|
||||||
|
if kind in okinds:
|
||||||
|
conn.execute(
|
||||||
|
sa.update(personas_table)
|
||||||
|
.where(personas_table.c.persona_id == oid)
|
||||||
|
.values(is_default=0, updated=now)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_single_default_persona(conn: Any, personas_table: Any, kind: str) -> None:
|
||||||
|
"""Post-promote backstop: raise (rolling back the enclosing
|
||||||
|
transaction) if more than one enabled default applies to *kind* — a
|
||||||
|
concurrent promotion that slipped past serialization must fail loudly,
|
||||||
|
never commit a nondeterministic default."""
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.select(personas_table.c.persona_id, personas_table.c.applies_to_kinds).where(
|
||||||
|
personas_table.c.is_default == 1
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
holders = [oid for oid, kr in rows if kind in (json.loads(kr) if kr else [])]
|
||||||
|
if len(holders) > 1:
|
||||||
|
raise ValueError(f"concurrent default-persona change detected for kind {kind!r}; retry")
|
||||||
|
|
||||||
|
|
||||||
HEURISTIC_RULE_MUTABLE = frozenset(
|
HEURISTIC_RULE_MUTABLE = frozenset(
|
||||||
{
|
{
|
||||||
"name",
|
"name",
|
||||||
|
|||||||
@@ -273,8 +273,11 @@ def compose_system_message(
|
|||||||
# model as an IC engineer ("you read before you edit, commits
|
# model as an IC engineer ("you read before you edit, commits
|
||||||
# you make..."); coordinators need an orchestrator framing
|
# you make..."); coordinators need an orchestrator framing
|
||||||
# instead ("you decompose, delegate, monitor, synthesise").
|
# instead ("you decompose, delegate, monitor, synthesise").
|
||||||
# A persona's base_override replaces exactly this module.
|
# A persona's base_override replaces exactly this module. Truthy
|
||||||
if base_override is not None:
|
# check, not ``is not None``: the stamp codec documents ``""`` as
|
||||||
|
# "use the kind's stock BASE", so the empty string must never
|
||||||
|
# compose an empty BASE regardless of which caller forwards it.
|
||||||
|
if base_override:
|
||||||
parts.append(base_override)
|
parts.append(base_override)
|
||||||
else:
|
else:
|
||||||
base_module = "base_coordinator.md" if kind == WorkstreamKind.COORDINATOR else "base.md"
|
base_module = "base_coordinator.md" if kind == WorkstreamKind.COORDINATOR else "base.md"
|
||||||
|
|||||||
@@ -700,7 +700,12 @@ function loadDashboard() {
|
|||||||
const projP = window.TurnstoneProjects
|
const projP = window.TurnstoneProjects
|
||||||
? window.TurnstoneProjects.refreshProjects()
|
? window.TurnstoneProjects.refreshProjects()
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
Promise.all([dashP, sessP, projP])
|
// Same for the personas cache — the PERSONA column falls back to raw
|
||||||
|
// slugs until display names arrive.
|
||||||
|
const persP = window.TurnstonePersonas
|
||||||
|
? window.TurnstonePersonas.refreshPersonas()
|
||||||
|
: Promise.resolve();
|
||||||
|
Promise.all([dashP, sessP, projP, persP])
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
const dashData = res[0];
|
const dashData = res[0];
|
||||||
const wsList = dashData.workstreams || [];
|
const wsList = dashData.workstreams || [];
|
||||||
@@ -931,13 +936,18 @@ function _initSavedWsTable() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// The PROJECT column resolves names from the shared projects cache,
|
// The PROJECT and PERSONA columns resolve names from the shared caches,
|
||||||
// which fills asynchronously — re-render once names arrive.
|
// which fill asynchronously — re-render once names arrive.
|
||||||
if (window.TurnstoneProjects) {
|
if (window.TurnstoneProjects) {
|
||||||
window.TurnstoneProjects.onProjectsChange(function () {
|
window.TurnstoneProjects.onProjectsChange(function () {
|
||||||
if (_wsTable) _wsTable.render();
|
if (_wsTable) _wsTable.render();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (window.TurnstonePersonas) {
|
||||||
|
window.TurnstonePersonas.onPersonasChange(function () {
|
||||||
|
if (_wsTable) _wsTable.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML inline-onclick wrappers — keep the global names the existing markup
|
// HTML inline-onclick wrappers — keep the global names the existing markup
|
||||||
@@ -2183,6 +2193,10 @@ function applyRosterSnapshot(list, opts) {
|
|||||||
cur.state = ws.state || cur.state || "idle";
|
cur.state = ws.state || cur.state || "idle";
|
||||||
cur.parent_ws_id = ws.parent_ws_id || null;
|
cur.parent_ws_id = ws.parent_ws_id || null;
|
||||||
cur.project_id = ws.project_id || null;
|
cur.project_id = ws.project_id || null;
|
||||||
|
// Preserve-on-missing (unlike project_id's hard overwrite): roster
|
||||||
|
// snapshots from pre-persona nodes omit the field during a rolling
|
||||||
|
// upgrade, and persona is immutable post-create — keeping the known
|
||||||
|
// value beats flapping labels to the slug fallback.
|
||||||
cur.persona = ws.persona || cur.persona || "";
|
cur.persona = ws.persona || cur.persona || "";
|
||||||
workstreams[ws.id] = cur;
|
workstreams[ws.id] = cur;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user