ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift. Manage endpoints
never become tools. A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.
POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout. Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:47:50 -06:00
|
|
|
"""Chat modes: a mode is a system prompt plus a tool allowlist, enforced server-side in the agent
|
|
|
|
|
loop — the model cannot opt out. Tool names are the /api/ai blueprint's operationIds."""
|
|
|
|
|
|
|
|
|
|
COMMON_PROMPT = (
|
|
|
|
|
'You are the WROLPi assistant, running locally on the user\'s own offline WROLPi server.'
|
|
|
|
|
' WROLPi is an off-grid digital library: videos, archived web pages, ebooks, Zim encyclopedias,'
|
|
|
|
|
' maps, and inventories. You have no internet access.'
|
|
|
|
|
' Answer briefly and plainly. Use the provided tools to find real information before answering;'
|
|
|
|
|
' never invent library content or system state.'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
MODES = {
|
|
|
|
|
'help': dict(
|
|
|
|
|
system_prompt=(
|
|
|
|
|
f'{COMMON_PROMPT}'
|
|
|
|
|
' Your job is to teach the user how to use WROLPi.'
|
2026-08-27 12:21:44 -06:00
|
|
|
' You do NOT know how WROLPi works from memory: before answering ANY question, you MUST'
|
|
|
|
|
' first call search_help, then read the matching page with get_help_doc, and answer only'
|
ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift. Manage endpoints
never become tools. A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.
POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout. Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:47:50 -06:00
|
|
|
' from what the docs actually say. Always include the link of every help page you used.'
|
|
|
|
|
' If the docs do not cover the question, say so.'
|
|
|
|
|
),
|
|
|
|
|
tools=(
|
|
|
|
|
'search_help',
|
|
|
|
|
'get_help_doc',
|
|
|
|
|
'get_system_status',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
'research': dict(
|
|
|
|
|
system_prompt=(
|
|
|
|
|
f'{COMMON_PROMPT}'
|
|
|
|
|
' Your job is to be the librarian: find and summarize content from the user\'s library.'
|
|
|
|
|
' Search first, then read captions, page text, or entries when the user wants detail.'
|
2026-08-27 12:44:37 -06:00
|
|
|
' When the user asks about a channel, website, or their whole library, call'
|
|
|
|
|
' list_collections first to learn the exact names (spellings often differ).'
|
|
|
|
|
' list_collections only gives names — it never tells you what content exists. You have'
|
|
|
|
|
' not summarized a channel until you have called search_videos with its channel_id (and'
|
|
|
|
|
' NO search_str) and read the resulting titles.'
|
|
|
|
|
' To browse everything of one kind (e.g. "summarize my archives"), call the search tool'
|
|
|
|
|
' WITHOUT search_str — that lists the newest items.'
|
|
|
|
|
' When a search finds nothing, do not give up: retry with fewer or different words, or'
|
|
|
|
|
' browse with list_collections.'
|
ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift. Manage endpoints
never become tools. A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.
POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout. Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:47:50 -06:00
|
|
|
' ALWAYS include the WROLPi link for every item you mention, exactly as the tools return it.'
|
|
|
|
|
' Never give external URLs. When a search returns a large total, narrow the query instead'
|
2026-08-27 12:44:37 -06:00
|
|
|
' of paging.'
|
ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift. Manage endpoints
never become tools. A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.
POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout. Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:47:50 -06:00
|
|
|
),
|
|
|
|
|
tools=(
|
|
|
|
|
'search_all',
|
|
|
|
|
'search_videos',
|
|
|
|
|
'get_video',
|
|
|
|
|
'get_video_captions',
|
|
|
|
|
'search_archives',
|
|
|
|
|
'get_archive_text',
|
|
|
|
|
'search_docs',
|
|
|
|
|
'get_doc',
|
|
|
|
|
'list_zims',
|
|
|
|
|
'search_zims',
|
|
|
|
|
'get_zim_entry',
|
|
|
|
|
'list_collections',
|
|
|
|
|
'list_inventories',
|
|
|
|
|
'get_inventory',
|
|
|
|
|
'read_file',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
'system': dict(
|
|
|
|
|
system_prompt=(
|
|
|
|
|
f'{COMMON_PROMPT}'
|
|
|
|
|
' Your job is to troubleshoot the user\'s WROLPi.'
|
|
|
|
|
' Start every diagnosis with get_system_status; read a failing service\'s logs with'
|
|
|
|
|
' get_service_logs; check drives with list_disks.'
|
|
|
|
|
' You CANNOT execute commands or change anything — you can only read.'
|
|
|
|
|
' Only suggest commands that appear in the help documentation: find them with search_help,'
|
|
|
|
|
' quote them exactly, and include the help page link.'
|
|
|
|
|
),
|
|
|
|
|
tools=(
|
|
|
|
|
'get_system_status',
|
|
|
|
|
'list_services',
|
|
|
|
|
'get_service_logs',
|
|
|
|
|
'list_disks',
|
|
|
|
|
'search_help',
|
|
|
|
|
'get_help_doc',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
ai: ground answers in a live library map, budget the context
Response-quality work, all aimed at giving the model enough context to
answer accurately:
- Every chat's system prompt now carries a live library map (today's
date, counts, and the exact names of channels, domains, zims, and
inventories, capped and cached 60s). Exact spellings are in context
before the first token, and the list_collections round-trip — minutes
on slow hardware — is usually unnecessary.
- The request is budgeted against the model's real context: oldest
turns are trimmed first, the current question is never dropped, and
an over-budget turn gets a clear "start a new conversation" error
instead of a silently truncated prompt. cache_prompt reuses the KV
cache for the stable prefix, which is most of the latency on a Pi.
- context_size plumbing fixed: selecting a model adopts its catalog
default (the 4B was running at 8k instead of 16k), and llama-server
runs --parallel 1 so the single user gets the whole context.
- temperature is configurable in ai.yaml (default 0.3 — small models
call tools far more reliably near 0.2-0.3 than at server defaults).
- One worked tool-use example per mode is prepended to every
conversation; small models imitate far better than they obey.
- scripts/ai_eval.py scores the assistant against a live WROLPi on
mechanical checks (finished, called the right tool first, included a
WROLPi link, no external URLs) so prompt changes stop being validated
by anecdote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 16:48:19 -06:00
|
|
|
|
|
|
|
|
def _example(*messages) -> list:
|
|
|
|
|
"""A compact worked exchange (user -> tool call -> tool result -> answer) prepended to every
|
|
|
|
|
conversation. Small models imitate far better than they obey; one example per mode is what
|
|
|
|
|
stops them answering from memory or quitting after the first tool call."""
|
|
|
|
|
return list(messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tool_call(call_id: str, name: str, arguments: str) -> dict:
|
|
|
|
|
return dict(role='assistant', content=None, tool_calls=[
|
|
|
|
|
dict(id=call_id, type='function', function=dict(name=name, arguments=arguments))])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tool_result(call_id: str, content: str) -> dict:
|
|
|
|
|
return dict(role='tool', tool_call_id=call_id, content=content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MODE_EXAMPLES = {
|
|
|
|
|
'help': _example(
|
|
|
|
|
dict(role='user', content='How do I tag a video?'),
|
|
|
|
|
_tool_call('ex_help_1', 'search_help', '{"search_str": "tag video"}'),
|
|
|
|
|
_tool_result('ex_help_1',
|
|
|
|
|
'{"results": [{"slug": "system/tags", "title": "Tags", "link": "/system/tags/",'
|
|
|
|
|
' "snippet": "…Tags group related content across the library…"}], "total": 1}'),
|
|
|
|
|
_tool_call('ex_help_2', 'get_help_doc', '{"slug": "system/tags"}'),
|
|
|
|
|
_tool_result('ex_help_2',
|
|
|
|
|
'{"content": "# Tags\\nOpen any video and press the tag button to add a tag.",'
|
|
|
|
|
' "next_offset": null, "total_chars": 62}'),
|
|
|
|
|
dict(role='assistant', content='Open any video and press the tag button to add a tag.'
|
|
|
|
|
' See the Tags help page: /system/tags/'),
|
|
|
|
|
),
|
|
|
|
|
'research': _example(
|
|
|
|
|
dict(role='user', content='Summarize the bakehouse channel'),
|
|
|
|
|
_tool_call('ex_res_1', 'list_collections', '{"kind": "channel"}'),
|
|
|
|
|
_tool_result('ex_res_1',
|
|
|
|
|
'{"results": [{"id": 7, "name": "Bakehouse", "kind": "channel",'
|
|
|
|
|
' "link": "/videos/channel/7/video"}], "total": 1}'),
|
|
|
|
|
_tool_call('ex_res_2', 'search_videos', '{"channel_id": 7}'),
|
|
|
|
|
_tool_result('ex_res_2',
|
|
|
|
|
'{"results": [{"id": 12, "kind": "video", "title": "Sourdough Basics",'
|
|
|
|
|
' "link": "/videos/12", "channel": "Bakehouse", "duration": 900}], "total": 1}'),
|
|
|
|
|
dict(role='assistant', content='The Bakehouse channel (/videos/channel/7/video) has one video:'
|
|
|
|
|
' Sourdough Basics (/videos/12), a 15 minute introduction to'
|
|
|
|
|
' sourdough baking.'),
|
|
|
|
|
),
|
|
|
|
|
'system': _example(
|
|
|
|
|
dict(role='user', content='Is everything running?'),
|
|
|
|
|
_tool_call('ex_sys_1', 'get_system_status', '{}'),
|
|
|
|
|
_tool_result('ex_sys_1',
|
|
|
|
|
'{"version": "0.28", "wrol_mode": false, "services": [{"name": "wrolpi-api",'
|
|
|
|
|
' "status": "running"}, {"name": "wrolpi-kiwix", "status": "failed"}], "errors": []}'),
|
|
|
|
|
dict(role='assistant', content='Almost: wrolpi-kiwix has failed; everything else is running.'
|
|
|
|
|
' I can read its logs to find out why.'),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift. Manage endpoints
never become tools. A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.
POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout. Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:47:50 -06:00
|
|
|
# Suggestion chips shown by the Chat tab; small models do much better when the opening message
|
|
|
|
|
# matches what the mode's prompt was tuned for.
|
|
|
|
|
MODE_SUGGESTIONS = {
|
|
|
|
|
'help': ['How do I download videos?', 'How do I add my own files?', 'What are Tags?'],
|
|
|
|
|
'research': ['Find videos about canning', 'What do I have about gardening?', 'Summarize my newest archives'],
|
|
|
|
|
'system': ['Why is my drive full?', 'Is everything running?', 'Why do downloads fail?'],
|
|
|
|
|
}
|