core-tool-agent
core-tool-agent is the LLM agent packaged as a Soundverse tool worker. It runs a
tool-calling loop over an LLM and drives other Soundverse tools by consuming
core-mcp as a client — so it is both a worker on the
queue and a consumer of the tool registry. It streams its reasoning and each sub-tool’s
progress live, persists the whole transcript to chat, and reports token usage for billing.
The billing for an agent run — and for every sub-tool the agent calls — is settled by a
second pipeline inside core-mcp, not by this worker. The worker only calls
ctx.report_usage(total_tokens). See The agent + MCP path
for the second billing pipeline and how nested tasks are queued.
The three tiers
Section titled “The three tiers”The agent registers as three separate MCP tools sharing one implementation
(AgentTool in app/tools/agent_tool.py).
They differ only in tier (which model to drive) and model (the stable Soundverse id used
for pricing / credential lookup):
| MCP tool name | model id |
tier | default LLM string | provider route | reasoning |
|---|---|---|---|---|---|
agent_flash |
agent-flash |
flash |
openai/Kimi-K2.6 |
OpenAI-compatible Azure AI Foundry | off |
agent_standard |
agent-standard |
standard |
anthropic/claude-sonnet-4-6 |
Anthropic direct | off |
agent_pro |
agent-pro |
pro |
anthropic/claude-opus-4-8 |
Anthropic direct | on |
Three tools rather than one tool with a model parameter because core-mcp resolves pricing,
credentials, and rate-limits per (environment, model, operation) before the worker
runs. Three tools let ops price/limit each tier independently and give the calling agent
three selectable names. All three share operation="agent", cost_unit="tokens", and are
priced free today (cost_base=0 under LICENSE_ROYALTY_FREE).
How a tier picks its provider
Section titled “How a tier picks its provider”litellm routes by the model-string prefix. resolve_model_config in
app/llm/provider.py
maps each prefix to its endpoint + key:
anthropic/…→ Anthropic’s native Messages API (AnthropicSettings;ANTHROPIC_API_KEY, base normally unset →api.anthropic.com). Native Messages API means real prompt caching and “thinking”, unlike an OpenAI-compatible shim.openai/…→ an OpenAI-compatible endpoint — the Foundry/openai/v1route where Kimi-K2 is hosted (OpenAISettings;OPENAI_API_BASE/OPENAI_API_KEY, no api-version).azure/…→ classic Azure OpenAI (same creds plus an api-version).- anything else (
litellm_proxy/…) → the Soundverse litellm proxy (LITELLM_BASE_URL/LITELLM_API_KEY) — the fallback path for a tier that hasn’t been repointed.
Repoint a single tier per environment via AGENT_FLASH_MODEL / AGENT_STANDARD_MODEL /
AGENT_PRO_MODEL with no code change.
Per-tier reasoning (AGENT_FLASH_REASONING / _STANDARD_REASONING / _PRO_REASONING,
defaults off/off/on) exists because the tiers now span providers: the reasoning path omits
sampling params a reasoning model rejects — Anthropic Opus 4.8 returns 400 on temperature
— and gives the run the larger reasoning_max_tokens budget. Kimi-K2 and Claude Sonnet 4.6
accept temperature, so their tiers leave it off.
What this worker hosts
Section titled “What this worker hosts”The worker registers seven tools in
app/tools/__init__.py:ALL_TOOLS
— the three agent tiers plus four co-hosted leaf tools the agent orchestrates:
| tool | model id |
operation | role |
|---|---|---|---|
agent_flash / agent_standard / agent_pro |
agent-* |
agent |
the LLM orchestrator tiers |
lyric_writer |
lyric-writer |
write_lyrics |
single-shot lyrics (reuses the standard tier → Claude) |
song_namer |
song-namer |
name_song |
single-shot title candidates (reuses the standard tier) |
file_manager |
file-manager |
manage_file |
rename a library file / relink a song’s album-art cover (free, no provider) |
wait_for |
wait-for |
wait_for_task |
poll a long sub-generation to completion (free control-flow utility) |
lyric_writer and song_namer are not agents — one LLM call, no turn-loop — but they
reuse the agent’s standard tier through the shared provider boundary, so they stay in sync
with the agent’s model config for free.
The turn-loop
Section titled “The turn-loop”AgentRunner.run() in
app/agent/loop.py
drives a provider-neutral ChatModel in a tool-calling loop over core-mcp. process() is
synchronous (the fleet contract), so it runs the async loop via asyncio.run.
sequenceDiagram
autonumber
participant MCP as core-mcp
participant W as AgentRunner (this worker)
participant LLM as LLM tier (Claude / Kimi)
participant Sub as sub-tool (via core-mcp)
MCP->>W: QueueTask → fleet claims it
W->>MCP: list_tools (exclude self + internal)
W->>W: select advertised (skip/conditional, name-sorted)
W->>W: load history + resolve @-mentions
loop each step (until no tool calls, or max_steps)
W->>LLM: stream_turn(messages, tools)
LLM-->>W: reasoning deltas + tool calls
W->>Sub: call_tool(name, args, _message_id) [concurrent]
Sub-->>W: result (or a still-running handle)
W->>W: feed result back into messages
end
W->>MCP: report_usage(total tokens); final answer + step trace
On every sub-tool call three things happen by design:
- live — a typed
tool_call/tool_progress/tool_resultenvelope is emitted on the agent’s own task event stream (viaTASK_EVENT_TYPE_PARTIAL_OUTPUT), and the model’s reasoning streams as a separatethinkingenvelope; the frontend switches onkind. No new event enum or consumer-mapper change was needed. - durable — a
role=TOOLchat message is created and its id passed as the sub-call’s_message_id, so core-mcp projects that generation’s result onto it. The step trace persisted to the assistant message is deliberately URL-free (blob hashes, never signed SAS links) so a reload can re-mint stream URLs client-side without leaking a private URL. - traced — OTel spans
agent.run→agent.turn→agent.tool_callwrap the run (see Observability).
The default persona (build_default_system_prompt) directs song creation: call lyric_writer
first, then in a single turn issue song_namer + song_gen_v5 + album_art together so
they run concurrently. A turn’s tool calls are dispatched with asyncio.gather over one MCP
session (replies correlate by JSON-RPC id), then folded back in original order so the
assistant→tool pairing and step trace stay deterministic. max_steps defaults to 12
(AGENT_MAX_STEPS).
Notable subsystems
Section titled “Notable subsystems”The agent fetches every tool core-mcp advertises and converts them into the LLM request’s
tools block on every reasoning step — a tool’s full description + JSON Schema rides there
uncached. ToolPolicy in
app/agent/tools.py
is the seam that decides what to advertise, applied before to_openai() so a dropped
tool’s (possibly huge) description never enters the request:
skip— a hard kill-switch (AGENT_SKIP_TOOLS, comma-separated MCP names) that drops a tool without unregistering it in core-mcp. The canonical candidate isstitch, whose description ships a ~55K-char reference catalog + schema that would bloat every turn.conditional—name → predicate(prompt): advertise a tool only when its predicate matches (wired viakeyword_predicate, currently unused). This is the groundwork for lazy advertising — move a heavy tool fromskiptoconditionalso it costs nothing on unrelated runs but appears when the prompt needs it.
select_advertised name-sorts the surviving tools so the request’s tools prefix is
deterministic run-to-run, which is what lets automatic prefix-based prompt caching hit.
Prompt caching (AGENT_PROMPT_CACHE, default on) is regime-aware: the Anthropic route gets
explicit cache_control breakpoints on the system persona + tools head; the OpenAI-compatible
route caches automatically. Cache telemetry lands on span attributes
(llm.cache_read_tokens / llm.cache_hit_ratio) — watch it in SigNoz; 0.0 means a cold
miss.
Without history the loop built its message list as exactly [system, current-prompt], so a
follow-up like “make it longer” had no context.
app/agent/history.py
loads the project’s prior turns agent-side from ctx (the worker already holds
ctx.project_id and an authenticated chat client) via ctx.list_messages(). History is
bounded by both a message count (AGENT_HISTORY_MAX_MESSAGES, default 20, 0 = kill-switch)
and a char budget (AGENT_HISTORY_MAX_CHARS); the newest turns are kept verbatim and older
ones fold into a rolling summary once enough turns overflow the window
(AGENT_HISTORY_FOLD_THRESHOLD).
The summary is persisted as one pinned role=SYSTEM chat message, and a fold is visible:
a compaction PARTIAL_OUTPUT envelope drives a transient “Compacting earlier conversation…”
chip, like an IDE compaction notice. No proto change — it reuses ListMessages, the
SYSTEM role, and the partial-output path.
A user can @-ping a tool and a library file in chat. Self-describing tokens
`<@tool:UUID:name>` / `<@file:UUID:name>` embed in prompt_text (UUID fixed at 36
chars so the id↔name boundary is unambiguous). The agent parses them
(app/agent/mentions.py),
resolves files ownership-gated through ctx.resolve_file (drops any file the caller
doesn’t own), and injects an attachments block telling the model the exact ref token to pass.
Delivery is split by field kind at dispatch time:
- A typed
FileInputfield (thex-sv-file-inputmarker, e.g.AudioInput) is passed through untouched — the leaf tool resolves the token itself (ownership + mime + url/bytes). - An untyped legacy
strreference field whose value is wholly a file ref (e.g.song_gen_v5.song_reference) is materialized here into a signed download URL (AGENT_FILE_INPUT_DOWNLOAD_TTL, default 3600s), minted lazily at dispatch. The original arguments are never mutated, so no signed URL leaks into the durable step trace.
When a sub-generation outlives core-mcp’s grace window, call_tool returns a still-running
envelope carrying the child’s task_id (`status: running`) instead of a result — core-mcp
does this rather than hold the /mcp POST open past the ingress idle timeout. The agent then
awaits it itself: _await_generation polls the fast, free get_generation_result tool
every AGENT_POLL_INTERVAL (default 5s) up to AGENT_POLL_MAX_WAIT (default 1200s), keeping
the MCP session warm on a heartbeated worker lease. There is also a model-callable wait_for
tool for the same job. The mcp_sse_read_timeout (default 900s) must exceed core-mcp’s
CALL_TIMEOUT (default 600s) so a slow-but-progressing sub-tool doesn’t trip a premature
transport failure.
Field guide
Section titled “Field guide”cp .env.example .env # set ANTHROPIC_API_KEY / OPENAI_API_KEY (or LITELLM_API_KEY)make sync # uv syncmake run # python -m app.mainNeeds Redis, core-database, core-storage, and core-mcp reachable (REDIS_ADDR,
CORE_DATABASE_GRPC, CORE_STORAGE_GRPC, CORE_MCP_URL). Set
CORE_DATABASE_USE_TLS=false / CORE_STORAGE_USE_TLS=false for plaintext gRPC locally (both
default to TLS, since staging/prod front those services with TLS-only ACA ingress). With
OTEL_EXPORTER_OTLP_ENDPOINT unset it logs JSON to stdout and ships no telemetry.
Directorycore-tool-agent/
Directoryapp/
- main.py builds the
WorkerFleetoverALL_TOOLS, blocks untilSIGTERM - settings.py all
AGENT_*/WAIT_FOR_*knobs + provider settings classes Directorytools/
- agent_tool.py the three tier classes + recursion guard
- lyric_writer.py / song_namer.py single-shot text tools (standard tier)
- file_manager.py rename / relink-cover utility
- wait_for.py poll a long sub-generation to completion
- __init__.py
ALL_TOOLS— the only place main.py reads tools from
Directoryagent/
- loop.py the
AgentRunnerturn-loop - tools.py
ToolPolicy(advertising: skip / conditional) - history.py conversation-history window + rolling summary
- mentions.py
@-mention token parsing + file resolution - events.py the
PARTIAL_OUTPUTenvelope shapes
- loop.py the
Directoryllm/ the provider-swappable model layer (
ChatModelprotocol +LiteLLMModel)- …
- main.py builds the
Names only — never commit values. Connection/secret vars are owned by soundverse.config;
the AGENT_* / WAIT_FOR_* knobs are owned by this worker. See the
env-var catalog.
| var | purpose |
|---|---|
INTERNAL_RPC_SECRET |
internal gRPC bearer token |
REDIS_ADDR |
Redis (events + heartbeats + task wake) |
CORE_DATABASE_GRPC / CORE_STORAGE_GRPC |
task queue + registry + chat / blob uploads |
CORE_DATABASE_USE_TLS / CORE_STORAGE_USE_TLS |
per-service gRPC TLS (default true) |
CORE_MCP_URL |
the MCP endpoint the agent calls sub-tools through |
ANTHROPIC_API_KEY / ANTHROPIC_API_BASE |
Claude (standard/pro) direct-dial creds |
OPENAI_API_KEY / OPENAI_API_BASE |
OpenAI-compatible Foundry (Kimi flash) creds |
LITELLM_API_KEY / LITELLM_BASE_URL |
litellm proxy fallback for un-repointed tiers |
AGENT_FLASH_MODEL / AGENT_STANDARD_MODEL / AGENT_PRO_MODEL |
per-tier model string |
AGENT_FLASH_REASONING / _STANDARD_REASONING / _PRO_REASONING |
per-tier reasoning flag |
AGENT_MAX_STEPS / AGENT_MAX_TOKENS |
loop + per-turn token bounds |
AGENT_PROMPT_CACHE / AGENT_SKIP_TOOLS |
prompt caching / tool-advertising kill-switch |
AGENT_HISTORY_MAX_MESSAGES / _MAX_CHARS / _FOLD_THRESHOLD |
conversation-history bounds |
AGENT_POLL_INTERVAL / AGENT_POLL_MAX_WAIT |
async wait for a long sub-generation |
AGENT_FILE_INPUT_DOWNLOAD_TTL |
signed-URL lifetime for an untyped @-file arg |
MAX_CONCURRENCY / ORCHESTRATOR_CONCURRENCY |
leaf vs orchestrator worker lanes |
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP collector (unset → stdout only) |
make test # pytestmake check # ruff + pyright (strict) + pytest — the full CI gateThe loop, LLM model, and tools are unit-tested against in-memory fakes
(tests/fakes.py)
— no Redis, gRPC, storage, MCP server, or live LLM. litellm_model.py is intentionally
# pyright: basic (it wraps the untyped litellm boundary); everything else is strict.
Gotchas
Section titled “Gotchas”Deploy
Section titled “Deploy”Both workflows are workers (enable_ingress: false) that call the shared
service-template. Push to the branch to deploy: staging →
deploy-aca-staging.yml,
prod → deploy-aca-prod.yml. Runtime env comes from STAGING_* / PROD_* org
secrets/variables (the deploy engine strips the prefix): the provider keys are set as
STAGING_ANTHROPIC_API_KEY / STAGING_OPENAI_API_KEY (and the PROD_ equivalents). See
Deploy a service.
Related
Section titled “Related”- The agent + MCP path — the second billing pipeline + nested tasks
- core-mcp — the registry the agent consumes, and the schema-cache restart gotcha
- core-tool-sansaarm — the
song_gen_v5tool the persona orchestrates - Other tool workers — where
album_art(core-tool-media) lives - The soundverse-py SDK —
WorkerFleet+TaskContext - Tool catalog — every agent tier + leaf tool at a glance