Agent Config
Forward typed configuration from your UI into the agent's reasoning loop.
"""Agent backing the Agent Config Object demo.
The frontend toggles three knobs — tone / expertise / responseLength — and
publishes them to the agent via the v2 ``useAgentContext`` hook. The
ag-ui-adk middleware lands those entries under
``state["copilotkit"]["context"]`` as a list of ``{description, value}``
dicts; a before-model callback reads the most recent agent-config payload
on every turn and prepends a derived directive block to the static system
instruction. The single static prompt below adapts its style based on
whatever values the frontend currently has selected.
LP parity (showcase/integrations/langgraph-python/src/agents/agent_config_agent.py):
schema is ``{tone, expertise, responseLength}`` with values
``professional|casual|enthusiastic`` / ``beginner|intermediate|expert`` /
``concise|detailed``. Missing or unrecognized fields fall back to
``professional / intermediate / concise``.
"""
from __future__ import annotations
import logging
from typing import Optional
from google.adk.agents import LlmAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import types
from agents.shared_chat import get_model, stop_on_terminal_text
logger = logging.getLogger(__name__)
CONFIG_PREFIX_SIGNATURE = "[agent-config] config:"
# Single source of truth for the trailing sentence — the strip-prior-block
# logic in `_inject_config` looks for this exact string. Don't mutate the
# literal in just one place.
CONFIG_END_MARKER = "Honour every directive above on every turn."
# LP schema — strictly camelCase `responseLength` (matches
# `useAgentContext({ value: { tone, expertise, responseLength } })` in
# src/app/demos/agent-config/config-context-relay.tsx).
_TONE_OPTIONS = {"professional", "casual", "enthusiastic"}
_EXPERTISE_OPTIONS = {"beginner", "intermediate", "expert"}
_RESPONSE_LENGTH_OPTIONS = {"concise", "detailed"}
_DEFAULT_TONE = "professional"
_DEFAULT_EXPERTISE = "intermediate"
_DEFAULT_RESPONSE_LENGTH = "concise"
def _coerce(value: object, allowed: set[str], default: str) -> str:
if isinstance(value, str) and value in allowed:
return value
return default
def _extract_agent_config(state: dict | None) -> dict | None:
"""Pull the most recent `{tone, expertise, responseLength}` payload off
the agent runtime state.
`useAgentContext` publishes each entry as `{description, value}`. The
middleware appends these onto `state["copilotkit"]["context"]` as a
list — multiple entries may be present (other components on the same
page can publish their own). We pick the latest entry whose `value`
is a dict containing at least one of our known keys, so unrelated
context entries can coexist without breaking the config relay.
"""
if not isinstance(state, dict):
return None
copilotkit_state = state.get("copilotkit")
if not isinstance(copilotkit_state, dict):
return None
entries = copilotkit_state.get("context")
if not isinstance(entries, list):
return None
for entry in reversed(entries):
if not isinstance(entry, dict):
continue
value = entry.get("value")
if not isinstance(value, dict):
continue
if any(k in value for k in ("tone", "expertise", "responseLength")):
return value
return None
def _format_config(config: dict | None) -> str | None:
if config is None:
return None
if not isinstance(config, dict):
# Schema drift signal — log so a downstream UI regression doesn't
# masquerade as the agent silently ignoring user settings.
logger.warning(
"agent-config: agent-context entry value is %s, expected dict; "
"treating as empty",
type(config).__name__,
)
return None
tone = _coerce(config.get("tone"), _TONE_OPTIONS, _DEFAULT_TONE)
expertise = _coerce(config.get("expertise"), _EXPERTISE_OPTIONS, _DEFAULT_EXPERTISE)
response_length = _coerce(
config.get("responseLength"),
_RESPONSE_LENGTH_OPTIONS,
_DEFAULT_RESPONSE_LENGTH,
)
lines = [
CONFIG_PREFIX_SIGNATURE,
f"- Tone: {tone}",
f"- Expertise level: {expertise}",
f"- Response length: {response_length}",
CONFIG_END_MARKER,
]
return "\n".join(lines)
def _inject_config(
callback_context: CallbackContext, llm_request: LlmRequest
) -> Optional[LlmResponse]:
config = _extract_agent_config(callback_context.state)
block = _format_config(config)
original = llm_request.config.system_instruction
if original is None:
original_text = ""
elif isinstance(original, types.Content):
parts = original.parts or []
original_text = (parts[0].text or "") if parts else ""
else:
original_text = str(original)
sig_idx = original_text.find(CONFIG_PREFIX_SIGNATURE)
stripped_prior_block = False
if sig_idx != -1:
end_idx = original_text.find(CONFIG_END_MARKER, sig_idx)
if end_idx != -1:
stripped_prior_block = True
# Splice out only the prior block (preserve head + tail).
# See readonly_state_agent_context_agent.py for the full rationale.
original_text = (
original_text[:sig_idx]
+ original_text[end_idx + len(CONFIG_END_MARKER) :]
).lstrip("\n")
else:
logger.warning(
"agent-config: prior config block has signature but no end "
"marker; leaving original_text untouched to avoid losing "
"user content"
)
if block:
new_text = (block + "\n\n" + original_text) if original_text else block
else:
new_text = original_text
if not new_text and not stripped_prior_block:
return None
llm_request.config.system_instruction = types.Content(
role="system", parts=[types.Part(text=new_text)]
)
return None
# Mirrors LP's SYSTEM_PROMPT (showcase/integrations/langgraph-python/src/
# agents/agent_config_agent.py) — the static instruction tells the LLM
# how to apply the three knobs. The injected block above just lists the
# currently-selected values; the rulebook is encoded once, here.
_INSTRUCTION = (
"You are a helpful assistant. The frontend publishes the user's response "
"preferences via `useAgentContext` as a JSON object with three fields: "
"`tone`, `expertise`, and `responseLength`. Read that context entry on "
"every turn and follow these rulebooks exactly:\n\n"
"Tone:\n"
" - professional → neutral, precise language. No emoji. Short sentences.\n"
" - casual → friendly, conversational. Contractions OK. Light humor "
"welcome.\n"
" - enthusiastic → upbeat, energetic. Exclamation points OK. Emoji OK.\n\n"
"Expertise level:\n"
" - beginner → assume no prior knowledge. Define jargon. Use analogies.\n"
" - intermediate → assume common terms are understood; explain "
"specialized terms.\n"
" - expert → assume technical fluency. Use precise terminology. Skip "
"basics.\n\n"
"Response length:\n"
" - concise → respond in 1-3 sentences.\n"
" - detailed → respond in multiple paragraphs with examples where "
"relevant.\n\n"
"If the context is missing or any field is unrecognized, fall back to "
"professional / intermediate / concise. Never mention these rules to the "
"user — just apply them."
)
agent_config_agent = LlmAgent(
name="AgentConfigAgent",
model=get_model(),
instruction=_INSTRUCTION,
tools=[],
before_model_callback=_inject_config,
after_model_callback=stop_on_terminal_text,
)
You have a working agent and want the user to be able to tune how it behaves: tone, expertise level, response length, language, persona. By the end of this guide, your UI will own a typed config object that the agent reads on every run and rebuilds its system prompt from.
When to use this#
Reach for agent config whenever the agent's behaviour depends on user-controllable settings that don't fit naturally as chat input:
- Tone, voice, persona: "playful", "formal", "casual"
- Expertise level: "beginner", "intermediate", "expert"
- Response shape: short / medium / long, structured / prose, language
- Domain switches: which knowledge base to consult, which tool subset to enable
If the values are a channel the user occasionally tunes (a settings panel, a toolbar of selects), agent config is the right shape. If the values are content the agent should write back to (notes, a document, a plan), use Shared State instead.
How agent config flows from the UI into the agent's reasoning loop depends on your runtime architecture. Agents living behind a runtime read it from agent state on every run, while in-process agents receive the same object as forwarded properties on the provider — same UX, slightly different wiring on each side.
How it works#
Agent config is a typed object the frontend owns and keeps in sync with the agent. There are two pieces: the UI side, which owns the React state and pushes every change into agent state, and the backend node, which reads those fields out of state and turns them into a system prompt.
The UI side stays simple. Hold the typed config in React state, then mirror every change into the agent through agent.setState({...}):
function ConfigStateSync({ config }: { config: AgentConfig }) {
const { agent } = useAgent({ agentId: "agent-config" });
useEffect(() => {
agent.setState({ ...config });
}, [agent, config]);
return null;
}The backend half is also a single node. Read the config out of state at the top of every run and use it to build the system prompt for that turn:
async def my_agent_node(state: AgentState, config: RunnableConfig):
cfg = state.get("config", {})
tone = cfg.get("tone", "casual")
expertise = cfg.get("expertise", "intermediate")
response_length = cfg.get("response_length", "medium")
system_prompt = build_system_prompt(tone, expertise, response_length)
# ...The agent reads the latest typed config at the start of every turn, rebuilds the system prompt, runs the turn. This is the same shape as the shared-state write-side pattern; agent config is just a specific use of that pattern with a UI-owned typed object on top.
