Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ Gecko Suite, through the two functions every job already calls, `build_provider`
proposal reaches the library, and only when a person presses Apply. A new tool that
writes would break that, so make it a proposal kind instead. Every tool re-checks that
the asset is the caller's: the model chooses the ids.
`web_search` is one of the looking tools: the suite runs the search (its own backend, else
DuckDuckGo) and credits it to the conversation, and what comes back is for the model to
read and cite, never a write. A **persona** is the suite's, chosen per turn and kept on the
turn row so a rerun after a restart is asked as the same one; the suite applies its prompt
and model, and this app only names it.
- **The embedding width is `SUITE_EMBEDDING_DIMENSIONS` (512) and must not change under a
populated library:** vectors of another width cannot be compared with the existing index.

Expand Down
30 changes: 30 additions & 0 deletions backend/alembic/versions/20261104_0900_add_assistant_persona.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add the persona an assistant turn was asked as

A turn may be asked as one of the suite's personas. Kept on the turn so a rerun after a
restart is asked as the same one. Nullable: every existing turn was asked as none.

Revision ID: e8c41a7b2d56
Revises: c3a95e7d1f42
Create Date: 2026-11-04 09:00:00.000000+00:00
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


revision: str = 'e8c41a7b2d56'
down_revision: Union[str, None] = 'c3a95e7d1f42'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table('assistant_turn', schema=None) as batch_op:
batch_op.add_column(sa.Column('persona_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table('assistant_turn', schema=None) as batch_op:
batch_op.drop_column('persona_id')
2 changes: 2 additions & 0 deletions backend/app/assistant/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
- You cannot change the library. propose_changes and propose_enrichment record a proposal; the \
person reads it and applies it or throws it away. Say plainly what you are proposing and why. \
Never say a change "has been made".
- The web is for what the library cannot say. Use web_search sparingly, and say where a fact
came from. Never present something from the web as if it were in the asset itself.
- Prefer the tags the person already uses (list_tags). Invent a new one only when none fits.
- Change as little as you must. Leave a good name, description or summary alone. Propose \
changes to the assets the request is about, not to everything you happened to see.
Expand Down
32 changes: 31 additions & 1 deletion backend/app/assistant/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@
"list_tags",
"The person's existing tags, most used first. Look here before proposing a tag.",
{"type": "object", "properties": {}}),
ToolSpec(
"web_search",
"Search the web, for what the library cannot say: who someone is, when something "
"happened, what a term means. Returns titles, links and short passages. Use it to "
"inform a proposal or an answer, and say where a fact came from; it is not a way to "
"find the person's own assets (use search_assets).",
{"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}),
ToolSpec(
"propose_changes",
"Propose edits to one asset's name, description, summary or tags. Nothing is changed "
Expand All @@ -94,7 +101,10 @@
]

# Tools that only look. The worker reports a step for the rest.
READS = frozenset({"search_assets", "get_asset", "read_transcript", "list_tags"})
READS = frozenset({"search_assets", "get_asset", "read_transcript", "list_tags", "web_search"})

# What a web search may hand back to the model in one call. A page of results, not a crawl.
MAX_WEB_CHARS = 6_000


def _fail(message: str) -> tuple:
Expand Down Expand Up @@ -122,6 +132,9 @@ def __init__(self, user_id: str, open_session: Callable[[], ContextManager[Sessi
self._step = on_step
# asset_id -> the proposal's entry for it, in the order they were first proposed.
self.items: Dict[str, dict] = {}
# Set by the worker once it holds a connection to the suite: `query -> results`.
# Unset, the tool says so instead of failing.
self.search: Optional[Callable[[str], list]] = None

# ─── dispatch ────────────────────────────────────────────────────────────

Expand All @@ -132,6 +145,7 @@ def handlers(self) -> Dict[str, Callable[[dict], object]]:
"get_asset": self.get_asset,
"read_transcript": self.read_transcript,
"list_tags": self.list_tags,
"web_search": self.web_search,
"propose_changes": self.propose_changes,
"propose_enrichment": self.propose_enrichment,
}
Expand Down Expand Up @@ -227,6 +241,22 @@ def read_transcript(self, args: dict) -> object:
used += len(line) + 1
return "\n".join(lines)

def web_search(self, args: dict) -> object:
query = _clean(args.get("query"))
if not query:
return _fail("Give a query to search the web for.")
if self.search is None:
return _fail("Web search is not available right now.")
self._step("search", f"Searching the web: {query!r}")
try:
results = self.search(query)
except Exception as exc: # noqa: BLE001 - a failed search is information for the model
return _fail(f"The web search failed: {exc}")
from gecko_suite_client import format_results

text = format_results(results)
return text[:MAX_WEB_CHARS] + ("\n[cut off]" if len(text) > MAX_WEB_CHARS else "")

def list_tags(self, args: dict) -> object:
with self._session() as session:
counts = tag_service.usage_counts(session, self.user_id)
Expand Down
12 changes: 8 additions & 4 deletions backend/app/assistant/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from contextlib import closing
from typing import List, Optional, Tuple

from gecko_suite_client import Stopped, run_agent
from gecko_suite_client import Stopped, run_agent, web_search
from sqlmodel import Session, func, select

from app import suite
Expand Down Expand Up @@ -70,6 +70,7 @@ def _run(turn_id: str) -> None:
steps="[]", reply="", proposal=None, outcome="", cost=None)
chat = session.get(AssistantSession, turn.session_id)
user_id, prompt, focus_id = turn.user_id, turn.prompt, turn.focus_asset_id
persona_id = turn.persona_id
history = history_for(chat, turn_id) if chat else []
chat_id = turn.session_id
focus = None
Expand Down Expand Up @@ -105,7 +106,7 @@ def on_reply(reply) -> None:
working = T.Working(user_id, queue.session, on_step)
try:
with closing(suite.client(user_id)) as connection:
chat_model = suite.assistant_chat(connection, f"gam:assistant:{chat_id}")
chat_model = suite.assistant_chat(connection, f"gam:assistant:{chat_id}", persona_id)
model = chat_model.resolve()
if model is None:
raise suite.ProviderError("No AI model is set up for the assistant. Choose "
Expand All @@ -114,6 +115,9 @@ def on_reply(reply) -> None:
raise suite.ProviderError(
f"{model.get('label') or model.get('model_id')} can't use tools, which the "
"assistant needs. Route chat.assistant to another model in Gecko Suite.")
# The web, through the same connection, credited to this conversation.
working.search = lambda query: web_search(
connection, query, subject_ref=f"gam:assistant:{chat_id}")
conversation = chat_model.conversation(
system=[prompts.REFERENCE, prompts.context(focus=focus, library_size=size)],
tools=T.SPECS, history=history, prompt=prompt)
Expand Down Expand Up @@ -171,9 +175,9 @@ def _finish(turn_id: str, *, message: str, steps: List[dict], **fields) -> None:


def submit(session: Session, chat: AssistantSession, *, user_id: str, prompt: str,
focus_asset_id: Optional[str]) -> AssistantTurn:
focus_asset_id: Optional[str], persona_id: Optional[str] = None) -> AssistantTurn:
turn = AssistantTurn(id=str(uuid.uuid4()), session_id=chat.id, user_id=user_id,
prompt=prompt, focus_asset_id=focus_asset_id)
prompt=prompt, focus_asset_id=focus_asset_id, persona_id=persona_id)
append_message(chat, "user", prompt, turn.id)
if not chat.title:
chat.title = prompt.strip().splitlines()[0][:80]
Expand Down
4 changes: 4 additions & 0 deletions backend/app/models/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class AssistantTurn(SQLModel, table=True):
# The asset open in the library when the turn was asked, if any: "this" in a request
# means it.
focus_asset_id: Optional[str] = None
# The suite persona this turn was asked as, if any: its system prompt goes first and its
# model for chat.assistant replaces the routed one. Kept on the turn, not read afresh, so
# a turn that a restart reruns is asked as the same persona.
persona_id: Optional[str] = None
# JSON: [{"kind": "think"|"search"|"read"|"propose"|"issue", "text"}]
steps: str = "[]"
reply: str = ""
Expand Down
31 changes: 29 additions & 2 deletions backend/app/routers/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class TurnRead(BaseModel):
error: Optional[str] = None
prompt: str
focus_asset_id: Optional[str] = None
persona_id: Optional[str] = None
steps: List[dict] = []
reply: str = ""
proposal: Optional[dict] = None
Expand All @@ -82,6 +83,9 @@ class SessionDetail(SessionRead):
class TurnCreate(BaseModel):
prompt: str = Field(min_length=1, max_length=MAX_PROMPT)
focus_asset_id: Optional[str] = None
# One of the suite's personas (`GET /personas`), or none. The suite is the authority on
# whether it exists and may be used: an unknown one ends the turn with its sentence.
persona_id: Optional[str] = Field(default=None, max_length=64)


class ApplyBody(BaseModel):
Expand All @@ -100,7 +104,7 @@ def _turn_read(row: AssistantTurn) -> TurnRead:
return TurnRead(
id=row.id, session_id=row.session_id, status=row.status, stage=row.stage,
detail=row.detail, error=row.error_message, prompt=row.prompt,
focus_asset_id=row.focus_asset_id, steps=json.loads(row.steps or "[]"),
focus_asset_id=row.focus_asset_id, persona_id=row.persona_id, steps=json.loads(row.steps or "[]"),
reply=row.reply, proposal=apply_service.decode(row.proposal), outcome=row.outcome,
cost=row.cost, created_at=row.created_at.isoformat())

Expand Down Expand Up @@ -168,6 +172,28 @@ def _require_ready(user_id: str) -> None:
"Route chat.assistant to another model in Gecko Suite.")


class PersonaRead(BaseModel):
id: str
name: str
description: str = ""
avatar: Optional[str] = None


@router.get("/personas", response_model=ListResponse[PersonaRead])
def list_personas(user: CurrentUser) -> ListResponse[PersonaRead]:
"""The personas the person may ask as: the suite's, for GAM and for every app.

Only what a picker shows (never the system prompt). Empty when the suite has none or is
not there, and then the panel simply has no picker.
"""
if not suite.enabled():
return ListResponse(data=[], total=0, limit=0, offset=0)
rows = [PersonaRead(id=p["id"], name=p["name"], description=p.get("description") or "",
avatar=p.get("avatar")) for p in suite.personas(user.id)
if isinstance(p, dict) and p.get("id") and p.get("name")]
return ListResponse(data=rows, total=len(rows), limit=len(rows), offset=0)


# ─── conversations ───────────────────────────────────────────────────────────


Expand Down Expand Up @@ -243,7 +269,8 @@ def start_turn(session_id: str, body: TurnCreate, user: CurrentUser,
asset = session.get(Asset, body.focus_asset_id)
# Not an error when it isn't theirs: the turn simply has nothing in view.
focus = asset.id if asset is not None and asset.user_id == user.id else None
turn = worker.submit(session, chat, user_id=user.id, prompt=prompt, focus_asset_id=focus)
turn = worker.submit(session, chat, user_id=user.id, prompt=prompt, focus_asset_id=focus,
persona_id=body.persona_id or None)
return DataResponse(data=_turn_read(turn))


Expand Down
22 changes: 20 additions & 2 deletions backend/app/suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,30 @@ def build_llm(user_id: str, task: str = DEFAULT_TASK,

# ─── the assistant ───────────────────────────────────────────────────────────

# `READINESS_TIMEOUT` is defined under "speech" below; both are short calls on a path a
# person is waiting on.

# The job the assistant's conversation names, so it can be given a model of its own.
ASSISTANT_TASK = "chat.assistant"


def assistant_chat(connection: SuiteClient, subject: Optional[str]) -> SuiteChat:
return SuiteChat(connection, task=ASSISTANT_TASK, app=APP, subject_ref=subject)
def assistant_chat(connection: SuiteClient, subject: Optional[str],
persona_id: Optional[str] = None) -> SuiteChat:
return SuiteChat(connection, task=ASSISTANT_TASK, app=APP, subject_ref=subject,
persona_id=persona_id)


def personas(user_id: str) -> list[dict]:
"""The personas this person may ask the assistant as: the suite's, for GAM and for every
app. Empty for any reason at all (the suite down, none made): a picker with nothing in
it is not shown, and the assistant works without one."""
try:
with closing(client(user_id, timeout=READINESS_TIMEOUT)) as connection:
rows = connection.request("GET", "/api/v1/personas", params={"app": APP})
except SuiteError as exc:
logger.info("No personas from Gecko Suite: %s", sentence(exc))
return []
return rows if isinstance(rows, list) else []


# ─── speech ──────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading