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
32 changes: 32 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,38 @@
Blacki keeps the agent process small and delegates expensive capabilities to
managed APIs when they are enabled.

## Meal tool interface

When database-backed tools are enabled, the model receives `meals` and
`describe_action` instead of seven separate meal functions. For example:

```json
{
"action": "log_meal",
"arguments": {"description": "apple", "estimated_calories": 95}
}
```

`describe_action(domain="meals", action="log_meal")` returns the callable's
description and parameter schema without accessing user records or changing
permissions. Use discovery when the action parameters are unknown. The
dispatcher validates types strictly, rejects unknown fields and injected
context, and passes the original ADK user/session context to the existing
handler. Integer parameters require integers, rather than numeric strings or
floating-point values. Local storage, account ownership, private-chat export
checks, and Google Health synchronization remain owned by the existing meal
handlers. The nutrition policy supplies operating and consent rules.

Privacy redaction covers both new tools. Telegram progress and failure retry
guards resolve the selected action, so a failed summary does not block a
different meal action. Completed historical leaf-tool calls can remain in
conversation history. Resuming suspended calls to removed leaf-tool names is
not established by the continuation test.

This is a meals-only pilot. Training, sandbox/browser, and other domains retain
their current interfaces. See [the pilot measurement](meal-tools-benchmark.md)
for schema savings and the additional cost of discovery on short workflows.

## Runtime

The container runs:
Expand Down
98 changes: 98 additions & 0 deletions docs/meal-tools-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Meals tool pilot measurement

Measured on 2026-10-23 against the handlers and OpenRouter request adapter in
this change. These are local tokenizer estimates, not provider usage, bills,
or real-model task success measurements.

## Initial schemas

The seven original meal functions use 820 `cl100k_base` tokens or 850
`o200k_base` tokens after serialization by the production OpenRouter adapter.
The compact `meals` tool plus `describe_action` use 188 or 190 tokens,
respectively. This is a 77.1–77.6% reduction in the initial meals schema.

An all-listed-capabilities configuration has 50 tools before the pilot and
45 afterward. The benchmark payload exercises `build_tools` directly, so it
measures 49 and 44; the running agent adds `PreloadMemoryTool`. It enables
both search providers, sandbox, canonical training, weather, skills, memory,
custom data, reminders, Google Health, durable files, and speech. Gmail
remains unloaded. Its complete schema decreases from
6,885–7,009 tokens to 6,253–6,349 tokens, about 9.2–9.4%. This constructed
configuration is not a measurement of a running deployment.

The generated parameter models also supply discovery schemas. Discovery moves
selected schemas into conversation history; it does not eliminate their cost.

## Controlled workflow replay

A deterministic `BaseLlm` supplied model responses to real ADK `Runner` and
`App` instances. Each run used an isolated SQLite database, the production
root instruction, temporal instruction plugin, domain policy plugin, tool
registry, and `build_chat_request(...).create_kwargs(stream=False)` adapter.
The flat baseline used the original seven handlers and the original nutrition
policy from base commit `4e10c3f`. The compact runs used this change's policy.
The replay verified successful tool responses; the logging case independently
verified exactly one 95-calorie entry in SQLite.

The user messages were "Log one apple with 95 calories on 2026-10-20." and
"Show my calorie summary for 2026-10-20." A direct path called the operation
and then produced a final response. A cold path first called `describe_action`,
then called the operation, then produced a final response. Both compact paths
called the operation through `meals`.

Token counts below sum compact JSON representations of **all input request
payloads**, including tools, instructions, conversation history, and tool
results. They use `cl100k_base`; `o200k_base` showed the same direction.

From the repository root, reproduce the replay and both tokenizer counts with:

```bash
uv run --with tiktoken==0.12.0 python scripts/benchmark_meal_tools.py
```

The tokenizer is an isolated measurement dependency, not an application
dependency. Its first run may download public tokenizer vocabularies. The
harness reads the original policy with `git show 4e10c3f:src/blacki/prompts/policies.py`,
so that base commit must be available locally. It creates and closes temporary
databases and never reads deployment credentials. Temporal context can cause
small count differences on later dates.

| Workflow | Flat input tokens | Compact direct input tokens | Compact cold input tokens |
| --- | ---: | ---: | ---: |
| Log one meal | 19,332 | 18,179 | 28,078 |
| Read calorie summary | 19,324 | 18,171 | 27,635 |
| Model calls per workflow | 2 | 2 | 3 |

The direct path reduces total input by about 6%. Cold discovery increases it
by about 43–45% because it sends the complete context through another model
call. Tokenized model output JSON was 52–61 tokens for the flat paths, 60–69
for direct dispatch, and 96–104 for cold discovery. These output counts describe
the scripted responses, not actual generated or reasoning token usage.

The direct path assumes the model already knows the arguments without needing
discovery in this turn. It does not include an earlier discovery transcript and
is not a measurement of a warmed conversation. The replay does not establish
how often a real model selects either path, actual provider caching or token
framing, latency, or task success rates.

## Validation and rollout boundary

`tests/tools/test_meals.py` exercises all seven actions with real SQLite
storage, local saves with failed remote export and explicit requeue, rejected
arguments without writes, owner isolation, private-chat restrictions,
read-only discovery, privacy, progress, retry identity, and a real runner
continuing completed historical `log_meal` call/response events. The serialized
provider-schema test guards the initial payload reduction independently of
the domain dispatcher.

Run the relevant checks with:

```bash
uv run pytest tests/tools/test_meals.py tests/test_tools_boundary.py
```

The initial schema target is met. This pilot does **not** establish a general
cost reduction. Expansion to other domains requires representative real-model
evaluation of total input/output usage, discovery frequency, repeated actions,
latency, and mutation correctness. A separate discovery call on every ordinary
meal request is not a successful cost outcome.
235 changes: 235 additions & 0 deletions scripts/benchmark_meal_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
"""Controlled local ADK replay; no model or provider API calls."""

import ast
import asyncio
import importlib
import json
import shutil
import subprocess
import tempfile
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any

from google.adk.agents import LlmAgent
from google.adk.apps import App
from google.adk.models import BaseLlm, LlmRequest, LlmResponse
from google.adk.plugins.global_instruction_plugin import GlobalInstructionPlugin
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.tools import BaseTool, FunctionTool
from google.genai import types

import blacki.prompts.policies as policies
from blacki.container import AppContainer, reset_container_for_tests, set_container
from blacki.models.openrouter_request import build_chat_request
from blacki.prompts.instructions import (
return_global_instruction,
return_instruction_root,
)
from blacki.prompts.policies import NUTRITION_POLICY, DomainPolicyPlugin
from blacki.tools.meals import MEAL_ACTIONS, create_meal_tools
from blacki.tools.registry import ToolConfig, build_tools

# Keep the tokenizer out of Blacki's runtime dependencies. Run with
# uv run --with tiktoken==0.12.0 python scripts/benchmark_meal_tools.py
tiktoken = importlib.import_module("tiktoken")
encs = {name: tiktoken.get_encoding(name) for name in ("cl100k_base", "o200k_base")}


def tokens(value: Any) -> dict[str, int]:
text = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
return {name: len(encoding.encode(text)) for name, encoding in encs.items()}


git_path = shutil.which("git")
if git_path is None:
raise RuntimeError("Git is required to read the benchmark baseline")
base_source = subprocess.run( # noqa: S603
[git_path, "show", "4e10c3f:src/blacki/prompts/policies.py"],
capture_output=True,
text=True,
check=True,
).stdout
base_policy = next(
ast.literal_eval(node.value)
for node in ast.parse(base_source).body
if isinstance(node, ast.Assign)
and any(
isinstance(target, ast.Name) and target.id == "NUTRITION_POLICY"
for target in node.targets
)
)


class ReplayLlm(BaseLlm):
steps: list[tuple[str, dict[str, Any]]]
records: list[dict[str, Any]] = []
outputs: list[dict[str, int]] = []

async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
wire = build_chat_request(
llm_request, model="google/gemini-2.5-flash"
).create_kwargs(stream=False)
self.records.append(
{"input": tokens(wire), "tools": tokens(wire.get("tools", []))}
)
for content in llm_request.contents:
for part in content.parts or []:
if part.function_response:
response = part.function_response.response
if response is None or response.get("status") != "success":
raise RuntimeError("Controlled tool replay failed")
index = len(self.records) - 1
if index < len(self.steps):
name, args = self.steps[index]
content = types.Content(
role="model",
parts=[
types.Part(
function_call=types.FunctionCall(
id=f"call-{index}", name=name, args=args
)
)
],
)
else:
content = types.Content(
role="model",
parts=[types.Part(text="Completed the requested meal operation.")],
)
self.outputs.append(tokens(content.model_dump(exclude_none=True)))
yield LlmResponse(content=content)


async def run(case: tuple[str, dict[str, Any], str], mode: str) -> dict[str, Any]:
policies.NUTRITION_POLICY = base_policy if mode == "flat" else NUTRITION_POLICY
with tempfile.TemporaryDirectory(prefix="blacki-meals-benchmark-") as directory:
container = await AppContainer.create(Path(directory) / "meals.db")
await container.initialize_all_storages()
set_container(container)
try:
config = ToolConfig(
sqlite_path=str(Path(directory) / "meals.db"),
sandbox_enabled=True,
exa_api_key="benchmark",
brave_search_api_key="benchmark",
skills_dir=Path("src/blacki/skills"),
r2_files_enabled=True,
google_health_enabled=True,
kokoro_tts_base_url="http://example.invalid",
)
tools = build_tools(config, include_user_scoped_tools=True)
if mode == "flat":
tools = [
tool
for tool in tools
if not isinstance(tool, BaseTool)
or tool.name not in {"meals", "describe_action"}
]
tools.extend(MEAL_ACTIONS.values())
steps: list[tuple[str, dict[str, Any]]] = []
action, args, message = case
if mode == "cold":
steps.append(("describe_action", {"domain": "meals", "action": action}))
steps.append(
(action, args)
if mode == "flat"
else ("meals", {"action": action, "arguments": args})
)
model = ReplayLlm(model="controlled-replay", steps=steps)
agent = LlmAgent(
name="blacki",
model=model,
instruction=return_instruction_root(),
tools=tools,
)
app = App(
name="blacki",
root_agent=agent,
plugins=[
DomainPolicyPlugin(),
GlobalInstructionPlugin(return_global_instruction),
],
)
sessions = InMemorySessionService()
session = await sessions.create_session(
app_name="blacki",
user_id="telegram-chat-42",
state={"telegram_chat_id": "42", "telegram_chat_type": "private"},
)
runner = Runner(app=app, session_service=sessions)
async for _ in runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=types.Content(
role="user", parts=[types.Part(text=message)]
),
):
pass
for name, _args in steps:
if name == "describe_action":
continue
if action == "log_meal":
summary = await container.calorie_storage.get_daily_summary(
session.user_id, "2026-10-20"
)
if summary.entry_count != 1 or summary.total_calories != 95:
raise RuntimeError(
"Meal replay did not write exactly one expected entry"
)
await runner.close()
return {
"calls": len(model.records),
"initial_schema": model.records[0]["tools"],
"input_total": {
name: sum(row["input"][name] for row in model.records)
for name in encs
},
"output_total": {
name: sum(row[name] for row in model.outputs) for name in encs
},
}
finally:
reset_container_for_tests()
await container.close()


async def main() -> None:
results = {}
cases: list[tuple[str, dict[str, Any], str]] = [
(
"log_meal",
{"description": "apple", "estimated_calories": 95, "date": "2026-10-20"},
"Log one apple with 95 calories on 2026-10-20.",
),
(
"get_calorie_summary",
{"date": "2026-10-20"},
"Show my calorie summary for 2026-10-20.",
),
]
for case in cases:
results[case[0]] = {
mode: await run(case, mode) for mode in ("flat", "direct", "cold")
}
# Isolate actual meals declarations using the production adapter.
subtotals = {}
tool_groups: list[tuple[str, list[BaseTool]]] = [
("flat", [FunctionTool(f) for f in MEAL_ACTIONS.values()]),
("compact", create_meal_tools()),
]
for name, tools in tool_groups:
req = LlmRequest()
req.append_tools(tools)
subtotals[name] = tokens(
build_chat_request(req, model="google/gemini-2.5-flash").tools
)
results["meals_schema"] = subtotals
print(json.dumps(results, indent=2))


if __name__ == "__main__":
asyncio.run(main())
4 changes: 4 additions & 0 deletions src/blacki/prompts/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

NUTRITION_POLICY = """\
<nutrition_policy>
Use meals(action=..., arguments={...}) for meal and calorie operations. Action
names below identify meals actions, not separate tools. Use describe_action
with domain="meals" to read an unfamiliar action's parameters before calling it.
Use nutrition tracking only when the user explicitly logs, edits, deletes, or
summarizes intake, or clearly describes something they consumed. A food name
by itself is ambiguous: ask whether they want information or a log. General
Expand Down Expand Up @@ -127,6 +130,7 @@
DOMAIN_TOOL_NAMES = {
"nutrition": frozenset(
{
"meals",
"log_meal",
"get_calorie_summary",
"edit_meal",
Expand Down
Loading
Loading