feat: the orchestrating agent
Entry point for the estate: understands a request, consults the specialists that can settle it, composes one answer. LangGraph 1.2.11, a2a-sdk 1.1.2, both current as of August 2026. It holds no data, and the realm is what enforces that rather than the prompt. classic-models-agent-assistant is granted two optional scopes — one per specialist, plus the inference endpoint — and every domain audience it could request comes back Invalid scopes. An injection that succeeds here finds no path to a domain because there is none. "No scopes at all" was too absolute: the agent has to reason, and inference is not a domain. The two-hop token chain was the heaviest unproven thing in the design and it holds: sub stays the end user through all four hops, so their own roles apply at the far end and the MCP server filters tool discovery for the right human. azp names the last agent, not the first — Keycloak accepts actor_token and emits no act claim, so an orchestrator's identity survives only in the traces. Verified end to end: a routed question answered from the specialist's data; a question outside every remit refused rather than answered from memory; and a write proposed through the orchestrator, held by the specialist in input-required, relayed here unchanged, approved by the user through the same conversation, and written. The orchestrator never owns that decision. The graph is plan → delegate → plan, tools described by what a specialist decides rather than what it holds, delegations in one step run concurrently and capped at eight counted in calls. Rebuilt per turn, because its tools carry the user's token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"""Entry point: `python -m assistant_agent`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .a2a_server import build_app
|
||||
from .config import Settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
settings = Settings() # type: ignore[call-arg] # client_secret comes from the environment
|
||||
uvicorn.run(build_app(settings), host=settings.host, port=settings.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,184 @@
|
||||
"""The A2A facade: how other agents reach this one.
|
||||
|
||||
Notes on a2a-sdk 1.1, none of which are guessable and all of which were
|
||||
established against the running library:
|
||||
|
||||
- `a2a.types` are **protobuf** messages, not pydantic models.
|
||||
- Use `DefaultRequestHandlerV2`. `DefaultRequestHandler` speaks A2A 0.3 and
|
||||
rejects 1.0 traffic.
|
||||
- The JSON-RPC methods are `SendMessage`, `GetTask`, … The 0.3 spelling
|
||||
(`message/send`) is available through `enable_v0_3_compat=True`, kept on here
|
||||
so a client written against the older spelling still works.
|
||||
- **A client must send `A2A-Version: 1.0`.** With the header absent the server
|
||||
assumes 0.3 and answers `VERSION_NOT_SUPPORTED` — a confusing failure, since
|
||||
nothing in the request is actually wrong.
|
||||
- An executor must enqueue a `Task` before any status update when
|
||||
`context.current_task` is None, or the SDK answers
|
||||
"Agent should enqueue Task before TaskStatusUpdateEvent".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.server.request_handlers import DefaultRequestHandlerV2
|
||||
from a2a.server.routes.agent_card_routes import create_agent_card_routes
|
||||
from a2a.server.routes.jsonrpc_routes import create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
|
||||
from a2a.types import (
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
Part,
|
||||
Task,
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
)
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from .config import Settings
|
||||
from .orchestrator import Assistant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The bearer the caller presents, which this agent exchanges for MCP tokens.
|
||||
# It is the END USER's token, not the calling agent's: the whole scoping model
|
||||
# rests on `sub` staying the human throughout the chain while `azp` names the
|
||||
# agent doing the asking.
|
||||
SUBJECT_TOKEN_HEADER = "authorization"
|
||||
|
||||
|
||||
def build_agent_card(settings: Settings) -> AgentCard:
|
||||
"""One skill, and it is deliberately about routing rather than data.
|
||||
|
||||
This agent holds nothing. What it offers is that a caller can ask one
|
||||
question and have the right specialists consulted — which is a capability,
|
||||
not a data surface. Naming it `answer_about_the_business` rather than
|
||||
`query` keeps the distinction visible.
|
||||
"""
|
||||
return AgentCard(
|
||||
name="assistant",
|
||||
description=(
|
||||
"The entry point for the Classic Models estate. Understands a request, "
|
||||
"consults the specialists that can settle it, and composes one answer. "
|
||||
"Holds no data and no domain scope of its own."
|
||||
),
|
||||
version="0.1.0",
|
||||
default_input_modes=["text/plain"],
|
||||
default_output_modes=["text/plain"],
|
||||
capabilities=AgentCapabilities(streaming=False),
|
||||
supported_interfaces=[
|
||||
AgentInterface(
|
||||
url=settings.public_url,
|
||||
protocol_binding="JSONRPC",
|
||||
protocol_version="1.0",
|
||||
)
|
||||
],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="answer_about_the_business",
|
||||
name="Answer a question about the business",
|
||||
description=(
|
||||
"Route a question to the specialists that can settle it and compose "
|
||||
"their answers. Every figure comes from a specialist, within what the "
|
||||
"asking user is allowed to see."
|
||||
),
|
||||
tags=["orchestration", "routing"],
|
||||
examples=[
|
||||
"How many products are in the Classic Cars line, and which is dearest?",
|
||||
"Can we ship order 10248?",
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecutor(AgentExecutor):
|
||||
def __init__(self, agent: Assistant) -> None:
|
||||
self._agent = agent
|
||||
|
||||
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
updater = TaskUpdater(event_queue, context.task_id, context.context_id)
|
||||
|
||||
# The SDK requires the Task to exist before any status update.
|
||||
if context.current_task is None:
|
||||
await event_queue.enqueue_event(
|
||||
Task(
|
||||
id=context.task_id,
|
||||
context_id=context.context_id,
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED),
|
||||
)
|
||||
)
|
||||
await updater.start_work()
|
||||
|
||||
subject_token = _subject_token(context)
|
||||
if subject_token is None:
|
||||
# Refused rather than answered from the model's own knowledge. This
|
||||
# agent holds no data at all, so an answer produced without a
|
||||
# specialist is necessarily invented — and would be indistinguishable
|
||||
# from one that came from the estate.
|
||||
await updater.failed(
|
||||
message=updater.new_agent_message(
|
||||
parts=[Part(text="No bearer token on the request; cannot consult any specialist.")]
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
answer = await self._agent.run(
|
||||
context.get_user_input(), subject_token, context.context_id
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the caller, logged here
|
||||
logger.exception("turn failed")
|
||||
await updater.failed(
|
||||
message=updater.new_agent_message(parts=[Part(text=f"Turn failed: {exc}")])
|
||||
)
|
||||
return
|
||||
|
||||
# Always `completed`, never `input-required`. A specialist holding a
|
||||
# write parks its own task and its message is relayed here verbatim —
|
||||
# the user answers that specialist through this agent's next turn, and
|
||||
# the orchestrator never owns the decision.
|
||||
await updater.complete(message=updater.new_agent_message(parts=[Part(text=answer)]))
|
||||
|
||||
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
await TaskUpdater(event_queue, context.task_id, context.context_id).cancel()
|
||||
|
||||
|
||||
def _subject_token(context: RequestContext) -> str | None:
|
||||
headers = {}
|
||||
call = context.call_context
|
||||
if call is not None:
|
||||
headers = {k.lower(): v for k, v in (call.state.get("headers") or {}).items()}
|
||||
raw = headers.get(SUBJECT_TOKEN_HEADER)
|
||||
if not raw:
|
||||
return None
|
||||
return raw[7:] if raw.lower().startswith("bearer ") else raw
|
||||
|
||||
|
||||
def build_app(settings: Settings) -> Starlette:
|
||||
http_client = httpx.AsyncClient(verify=settings.verify, timeout=settings.http_timeout)
|
||||
card = build_agent_card(settings)
|
||||
handler = DefaultRequestHandlerV2(
|
||||
agent_executor=AssistantExecutor(Assistant(settings, http_client)),
|
||||
task_store=InMemoryTaskStore(),
|
||||
agent_card=card,
|
||||
)
|
||||
|
||||
# lifespan rather than on_shutdown: Starlette 1.x removed the latter.
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
|
||||
yield
|
||||
await http_client.aclose()
|
||||
|
||||
return Starlette(
|
||||
routes=create_agent_card_routes(card)
|
||||
+ create_jsonrpc_routes(handler, "/", enable_v0_3_compat=True),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Runtime configuration, entirely from the environment.
|
||||
|
||||
Nothing here has a value that would be wrong to commit, so the defaults are the
|
||||
lab's real endpoints. The one secret — this agent's Keycloak client secret — has
|
||||
no default and fails loudly if unset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="ASSISTANT_", env_file=".env")
|
||||
|
||||
# --- identity -----------------------------------------------------------
|
||||
keycloak_realm_url: str = "https://keycloak.sttlab.local/realms/classic-models"
|
||||
client_id: str = "classic-models-agent-assistant"
|
||||
client_secret: str = Field(..., description="Keycloak client secret; no default on purpose")
|
||||
|
||||
# --- inference ----------------------------------------------------------
|
||||
# This agent has no data of its own. The only thing it may reach besides
|
||||
# other agents is the inference endpoint, and the realm enforces exactly
|
||||
# that: `classic-models-agent-assistant` is granted two optional scopes, one
|
||||
# per specialist plus this one. Every domain audience it could ask for comes
|
||||
# back `Invalid scopes`.
|
||||
llm_base_url: str = "https://llm-dev.sttlab.local/llm/v1"
|
||||
llm_model: str = "google/gemini-3.7-flash"
|
||||
llm_scope: str = "llm-proxy-audience"
|
||||
|
||||
# --- transport ----------------------------------------------------------
|
||||
# The lab uses a private CA. Point this at the bundle rather than disabling
|
||||
# verification. See the note on `.local` names in the README: macOS resolves
|
||||
# them through mDNS and takes ~5s per host, which is why the timeout is not
|
||||
# the usual 5 or 10.
|
||||
ca_bundle: str | None = None
|
||||
http_timeout: float = 60.0
|
||||
|
||||
# A turn that fans out and loops has to stop somewhere. Counted in
|
||||
# delegations, not in model calls — one specialist consulted twice for the
|
||||
# same question is the loop worth catching.
|
||||
max_delegations: int = 8
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
# Advertised in the Agent Card, so it must be the URL other callers reach,
|
||||
# not the bind address.
|
||||
public_url: str = "http://localhost:8080/"
|
||||
|
||||
@property
|
||||
def token_endpoint(self) -> str:
|
||||
return f"{self.keycloak_realm_url}/protocol/openid-connect/token"
|
||||
|
||||
@property
|
||||
def verify(self) -> str | bool:
|
||||
return self.ca_bundle if self.ca_bundle else True
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Calling a specialist over A2A, with a token minted for that specialist alone.
|
||||
|
||||
One token per callee, exchanged per turn. Several narrow tokens rather than one
|
||||
broad one: a token minted for Catalogue is refused by every other agent, so a
|
||||
prompt injection that talks this orchestrator into consulting the wrong
|
||||
specialist fails at the realm rather than succeeding quietly.
|
||||
|
||||
The client is built per call for the same reason the Catalogue agent builds its
|
||||
MCP client per turn — a client is where the header lives, and a long-lived
|
||||
client is a long-lived token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from a2a import types as a2a_types
|
||||
from a2a.client import ClientConfig, ClientFactory
|
||||
|
||||
from .config import Settings
|
||||
from .identity import TokenExchanger
|
||||
from .specialists import Specialist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Answer:
|
||||
"""What a specialist said, and whether it finished saying it."""
|
||||
|
||||
specialist: str
|
||||
text: str
|
||||
state: str
|
||||
|
||||
@property
|
||||
def needs_the_user(self) -> bool:
|
||||
"""The specialist is holding something for a decision.
|
||||
|
||||
Catalogue parks a proposed write in `input-required` rather than making
|
||||
it. The orchestrator must relay that verbatim and stop — it has no
|
||||
standing to approve a write on the user's behalf, and paraphrasing the
|
||||
proposal would hide what is actually about to happen.
|
||||
"""
|
||||
return self.state == "TASK_STATE_INPUT_REQUIRED"
|
||||
|
||||
|
||||
def _text_of(message: a2a_types.Message) -> str:
|
||||
return "".join(part.text for part in message.parts if part.text)
|
||||
|
||||
|
||||
class Delegator:
|
||||
"""Sends one question to one specialist and waits for its answer."""
|
||||
|
||||
def __init__(self, settings: Settings, exchanger: TokenExchanger) -> None:
|
||||
self._settings = settings
|
||||
self._exchanger = exchanger
|
||||
|
||||
async def ask(
|
||||
self, specialist: Specialist, question: str, subject_token: str, context_id: str
|
||||
) -> Answer:
|
||||
token = await self._exchanger.exchange(subject_token, specialist.audience_scope)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
verify=self._settings.verify,
|
||||
timeout=self._settings.http_timeout,
|
||||
) as http:
|
||||
factory = ClientFactory(ClientConfig(httpx_client=http, streaming=False))
|
||||
client = await factory.create_from_url(specialist.url)
|
||||
try:
|
||||
request = a2a_types.SendMessageRequest(
|
||||
message=a2a_types.Message(
|
||||
message_id=f"{context_id}-{specialist.name}",
|
||||
# Carried across so the specialist keeps one conversation
|
||||
# per user conversation rather than one per question —
|
||||
# which is what lets its own approval flow resume.
|
||||
context_id=context_id,
|
||||
role=a2a_types.Role.ROLE_USER,
|
||||
parts=[a2a_types.Part(text=question)],
|
||||
)
|
||||
)
|
||||
text, state = "", "TASK_STATE_UNSPECIFIED"
|
||||
async for event in client.send_message(request):
|
||||
if event.HasField("task"):
|
||||
state = a2a_types.TaskState.Name(event.task.status.state)
|
||||
if event.task.status.HasField("message"):
|
||||
text = _text_of(event.task.status.message)
|
||||
elif event.HasField("message"):
|
||||
text = _text_of(event.message)
|
||||
state = "TASK_STATE_COMPLETED"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
if not text:
|
||||
text = f"({specialist.name} returned no answer; state {state})"
|
||||
logger.info("delegated to %s -> %s", specialist.name, state)
|
||||
return Answer(specialist=specialist.name, text=text, state=state)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""The orchestration graph: decide who to consult, consult them, compose.
|
||||
|
||||
A graph rather than a loop because the routing is the interesting part here.
|
||||
Which specialist is asked, whether two are asked at once, and where their
|
||||
answers are reconciled are the decisions this agent exists to make — and a graph
|
||||
states them where a chain of calls would bury them.
|
||||
|
||||
The graph is built per turn. Its tools carry the user's token, and a graph held
|
||||
across turns would be a token held across turns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated, Any, TypedDict
|
||||
|
||||
from langchain_core.messages import AIMessage, AnyMessage, SystemMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
from .config import Settings
|
||||
from .delegation import Answer, Delegator
|
||||
from .specialists import SPECIALISTS, by_name, describe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are the Assistant for the Classic Models estate. You are the single entry
|
||||
point: you understand what the user wants, consult the specialists that can
|
||||
settle it, and compose one answer.
|
||||
|
||||
You hold no data yourself. You cannot read the catalog, sales, customers,
|
||||
payments or workforce — the specialists can, each within what the user is
|
||||
allowed to see. When something is outside every specialist's remit, say so
|
||||
rather than answering from memory: an answer you invent is indistinguishable
|
||||
from one they gave you, and that is the failure mode this estate is built to
|
||||
avoid.
|
||||
|
||||
How to work:
|
||||
- Consult a specialist when the answer depends on data. Do not guess a figure,
|
||||
a name or a price you were not given, even if it seems obvious.
|
||||
- Ask several specialists in the same step when the question has independent
|
||||
parts. Do not chain calls that could have been made together.
|
||||
- Ask a complete question in plain language. A specialist answers questions; it
|
||||
does not run queries for you.
|
||||
- Report figures exactly as a specialist returned them, and say which one said
|
||||
what when it matters.
|
||||
- If a specialist asks for a decision — a write it is holding, a missing detail
|
||||
— relay what it said, unchanged, and stop. You do not approve on the user's
|
||||
behalf.
|
||||
- If a specialist refuses, report the refusal. It is the user's own permissions
|
||||
being applied, not an obstacle to work around.
|
||||
"""
|
||||
|
||||
|
||||
class State(TypedDict):
|
||||
"""What flows through the graph.
|
||||
|
||||
`delegations` counts calls rather than model turns: one specialist consulted
|
||||
twice for the same question is the loop worth catching, and it is invisible
|
||||
in a message count.
|
||||
"""
|
||||
|
||||
messages: Annotated[list[AnyMessage], add_messages]
|
||||
subject_token: str
|
||||
context_id: str
|
||||
delegations: int
|
||||
|
||||
|
||||
def _tool_schemas() -> list[dict[str, Any]]:
|
||||
"""One tool per specialist, described by what it decides."""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": f"ask_{s.name}",
|
||||
"description": describe(s),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"A complete question in plain language, carrying every detail "
|
||||
"the specialist needs. It cannot see the conversation."
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["question"],
|
||||
},
|
||||
},
|
||||
}
|
||||
for s in SPECIALISTS
|
||||
]
|
||||
|
||||
|
||||
def build_graph(settings: Settings, model: ChatOpenAI, delegator: Delegator) -> Any:
|
||||
"""Compile the graph for one turn."""
|
||||
bound = model.bind_tools(_tool_schemas())
|
||||
|
||||
async def plan(state: State) -> dict[str, Any]:
|
||||
"""Decide who to consult, or answer."""
|
||||
reply = await bound.ainvoke([SystemMessage(SYSTEM_PROMPT), *state["messages"]])
|
||||
return {"messages": [reply]}
|
||||
|
||||
async def delegate(state: State) -> dict[str, Any]:
|
||||
"""Consult every specialist the last step named, concurrently.
|
||||
|
||||
Concurrency is the point of fanning out at all: "can we ship order
|
||||
10248?" asks two specialists about independent things, and waiting for
|
||||
one before starting the other buys nothing.
|
||||
"""
|
||||
last = state["messages"][-1]
|
||||
assert isinstance(last, AIMessage)
|
||||
calls = last.tool_calls
|
||||
|
||||
budget = settings.max_delegations - state["delegations"]
|
||||
allowed, refused = calls[:budget], calls[budget:]
|
||||
|
||||
async def run(call: dict[str, Any]) -> ToolMessage:
|
||||
specialist = by_name(call["name"].removeprefix("ask_"))
|
||||
if specialist is None:
|
||||
# The model named something that is not a specialist. Answer the
|
||||
# tool call rather than raising: the run continues and the model
|
||||
# is told, which is more useful than a stack trace.
|
||||
return ToolMessage(
|
||||
content=f"There is no specialist called {call['name']!r}.",
|
||||
tool_call_id=call["id"],
|
||||
)
|
||||
answer = await delegator.ask(
|
||||
specialist,
|
||||
call["args"].get("question", ""),
|
||||
state["subject_token"],
|
||||
state["context_id"],
|
||||
)
|
||||
return ToolMessage(
|
||||
content=_render(answer), tool_call_id=call["id"], name=call["name"]
|
||||
)
|
||||
|
||||
results = list(await asyncio.gather(*(run(c) for c in allowed)))
|
||||
results.extend(
|
||||
ToolMessage(
|
||||
content=(
|
||||
"Not consulted: this turn has reached its limit of "
|
||||
f"{settings.max_delegations} delegations. Answer with what you have, "
|
||||
"and say what you could not check."
|
||||
),
|
||||
tool_call_id=call["id"],
|
||||
name=call["name"],
|
||||
)
|
||||
for call in refused
|
||||
)
|
||||
return {"messages": results, "delegations": state["delegations"] + len(allowed)}
|
||||
|
||||
def next_step(state: State) -> str:
|
||||
last = state["messages"][-1]
|
||||
if isinstance(last, AIMessage) and last.tool_calls:
|
||||
return "delegate"
|
||||
return END
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("plan", plan)
|
||||
graph.add_node("delegate", delegate)
|
||||
graph.add_edge(START, "plan")
|
||||
graph.add_conditional_edges("plan", next_step, {"delegate": "delegate", END: END})
|
||||
graph.add_edge("delegate", "plan")
|
||||
return graph.compile()
|
||||
|
||||
|
||||
def _render(answer: Answer) -> str:
|
||||
"""What the model sees of a specialist's reply.
|
||||
|
||||
A specialist waiting on a decision is labelled as such, because the model's
|
||||
instruction is to relay it unchanged — and an unlabelled reply reads like a
|
||||
finished answer.
|
||||
"""
|
||||
if answer.needs_the_user:
|
||||
return (
|
||||
f"{answer.specialist} is waiting for the user to decide. Relay this "
|
||||
f"verbatim and stop:\n\n{answer.text}"
|
||||
)
|
||||
return answer.text
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Token exchange: the end user's token in, one narrowly-scoped token per
|
||||
specialist out.
|
||||
|
||||
Copied from the Catalogue agent rather than shared. `classic-models-agent-platform`
|
||||
holds documentation and no code today, and two copies of forty lines is cheaper
|
||||
than a package that exists before anyone knows what belongs in it. Move it there
|
||||
when a third agent needs it unchanged.
|
||||
|
||||
Two measured facts about Keycloak 26.6 shape this, both of which cost time to
|
||||
discover and neither of which is obvious from the RFC:
|
||||
|
||||
**`actor_token` is silently ignored.** Standard Token Exchange accepts the
|
||||
parameter, returns a valid token, and emits no `act` claim — no error, no
|
||||
warning. Delegation therefore cannot be built on RFC 8693 `act`. The actor is
|
||||
identified by `azp`, which carries this agent's client id.
|
||||
|
||||
**The audience is requested as a scope.** Passing `audience=mcp-catalog` returns
|
||||
"Requested audience not available". Passing `scope=openid mcp-catalog-audience`
|
||||
works, and the resulting token carries `aud: [mcp-catalog]`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
||||
|
||||
|
||||
class TokenExchangeError(RuntimeError):
|
||||
"""Keycloak refused the exchange. Carries its response, which names the cause."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangedToken:
|
||||
access_token: str
|
||||
expires_at: float
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
# A conversation turn lasts seconds and a token five minutes, so this
|
||||
# margin only matters for a turn that stalls on a slow model.
|
||||
return time.monotonic() >= self.expires_at - 30
|
||||
|
||||
|
||||
class TokenExchanger:
|
||||
"""Exchanges a user token for audience-scoped tokens.
|
||||
|
||||
Caches per (subject token, scope) for the lifetime of one turn. Deliberately
|
||||
not a long-lived cache keyed by user: a token that outlives the turn it was
|
||||
minted for is a token that can be replayed after the user's session ended.
|
||||
"""
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient, *, token_endpoint: str, client_id: str, client_secret: str) -> None:
|
||||
self._client = client
|
||||
self._endpoint = token_endpoint
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._cache: dict[tuple[str, str], ExchangedToken] = {}
|
||||
|
||||
async def exchange(self, subject_token: str, audience_scope: str) -> str:
|
||||
key = (subject_token, audience_scope)
|
||||
cached = self._cache.get(key)
|
||||
if cached and not cached.expired:
|
||||
return cached.access_token
|
||||
|
||||
response = await self._client.post(
|
||||
self._endpoint,
|
||||
data={
|
||||
"grant_type": GRANT_TYPE,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": SUBJECT_TOKEN_TYPE,
|
||||
# Not `audience=...`. See the module docstring.
|
||||
"scope": f"openid {audience_scope}",
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise TokenExchangeError(
|
||||
f"exchange for {audience_scope!r} failed: HTTP {response.status_code} {response.text[:200]}"
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
token = ExchangedToken(
|
||||
access_token=payload["access_token"],
|
||||
expires_at=time.monotonic() + float(payload.get("expires_in", 60)),
|
||||
)
|
||||
self._cache[key] = token
|
||||
return token.access_token
|
||||
|
||||
|
||||
def caller_of(access_token: str) -> str:
|
||||
"""The username inside a token, for the prompt and nothing else.
|
||||
|
||||
Read without verifying the signature on purpose: this is a label shown to
|
||||
the model, never a decision. Every decision that depends on this identity is
|
||||
taken by Keycloak or by the gateway, both of which do verify.
|
||||
"""
|
||||
try:
|
||||
payload = access_token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload))
|
||||
except Exception: # noqa: BLE001 — a malformed token is the gateway's problem
|
||||
return "an unidentified caller"
|
||||
return claims.get("preferred_username") or claims.get("sub") or "an unidentified caller"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""One turn of the Assistant: exchange, build, run, remember.
|
||||
|
||||
Everything token-bearing is built per turn and dropped after it. What survives is
|
||||
the conversation, keyed by A2A `contextId` — kept in memory and bounded, which is
|
||||
why the deployment runs a single replica.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import AnyMessage, HumanMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from .config import Settings
|
||||
from .delegation import Delegator
|
||||
from .graph import build_graph
|
||||
from .identity import TokenExchanger
|
||||
|
||||
MAX_CONVERSATIONS = 200
|
||||
|
||||
|
||||
class Assistant:
|
||||
"""Holds what is stable across turns; rebuilds what must not be."""
|
||||
|
||||
def __init__(self, settings: Settings, http_client: httpx.AsyncClient) -> None:
|
||||
self._settings = settings
|
||||
self._exchanger = TokenExchanger(
|
||||
http_client,
|
||||
token_endpoint=settings.token_endpoint,
|
||||
client_id=settings.client_id,
|
||||
client_secret=settings.client_secret,
|
||||
)
|
||||
self._delegator = Delegator(settings, self._exchanger)
|
||||
self._history: OrderedDict[str, list[AnyMessage]] = OrderedDict()
|
||||
|
||||
async def run(self, prompt: str, subject_token: str, conversation_id: str) -> str:
|
||||
llm_token = await self._exchanger.exchange(subject_token, self._settings.llm_scope)
|
||||
model = ChatOpenAI(
|
||||
model=self._settings.llm_model,
|
||||
base_url=self._settings.llm_base_url,
|
||||
# An exchanged token, not a static key. The endpoint validates it as
|
||||
# a bearer, so inference is attributable to the same (agent, end
|
||||
# user) pair as every delegation.
|
||||
api_key=llm_token,
|
||||
timeout=self._settings.http_timeout,
|
||||
# No retries here: the estate's endpoint answers 429 with a
|
||||
# Retry-After the client library does not honour, and a blind retry
|
||||
# would burn the budget faster. Handled at the transport in the
|
||||
# specialists; left visible here rather than hidden behind a default.
|
||||
max_retries=0,
|
||||
)
|
||||
graph = build_graph(self._settings, model, self._delegator)
|
||||
|
||||
history = self._history.get(conversation_id, [])
|
||||
state = await graph.ainvoke(
|
||||
{
|
||||
"messages": [*history, HumanMessage(prompt)],
|
||||
"subject_token": subject_token,
|
||||
"context_id": conversation_id,
|
||||
"delegations": 0,
|
||||
}
|
||||
)
|
||||
self._remember(conversation_id, state["messages"])
|
||||
|
||||
final = state["messages"][-1]
|
||||
return _text_of(final)
|
||||
|
||||
def _remember(self, conversation_id: str, messages: list[AnyMessage]) -> None:
|
||||
self._history[conversation_id] = messages
|
||||
self._history.move_to_end(conversation_id)
|
||||
while len(self._history) > MAX_CONVERSATIONS:
|
||||
self._history.popitem(last=False)
|
||||
|
||||
|
||||
def _text_of(message: AnyMessage) -> str:
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
# Some providers return content as a list of blocks; keep the text ones.
|
||||
return "".join(
|
||||
block.get("text", "") for block in content if isinstance(block, dict)
|
||||
) or str(content)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""The specialists this agent may consult, and how to reach one.
|
||||
|
||||
A specialist is described here by what it *decides*, not by what data it holds.
|
||||
That is the difference the estate is built on: an A2A call returns an answer, so
|
||||
the orchestrator learns "38 products, the dearest is S10_1949" and never gains
|
||||
read access to the catalog domain.
|
||||
|
||||
Adding a specialist is three things: an entry below, an audience scope granted to
|
||||
`classic-models-agent-assistant` in the realm, and nothing else. If a specialist
|
||||
appears here without the realm grant, every call to it fails at the token
|
||||
exchange with `Invalid scopes` — loudly, and before any data moves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Specialist:
|
||||
"""One agent this orchestrator can delegate to."""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
audience_scope: str
|
||||
serves: str
|
||||
"""What it is for, in the words the model routing on it will read."""
|
||||
|
||||
decides: tuple[str, ...]
|
||||
"""The questions it can settle. Deliberately narrow.
|
||||
|
||||
These become the tool description. A specialist advertised as "ask it
|
||||
anything about products" turns the orchestrator into a data proxy by the
|
||||
back door — the very thing the topology exists to prevent.
|
||||
"""
|
||||
|
||||
|
||||
CATALOGUE = Specialist(
|
||||
name="catalogue",
|
||||
url="http://catalogue-agent.agents.svc.cluster.local:8080/",
|
||||
audience_scope="classic-models-agent-catalogue-audience",
|
||||
serves="product management: products, product lines, pricing, discontinuation",
|
||||
decides=(
|
||||
"how many products a product line holds, and which are the dearest or cheapest",
|
||||
"a product's details: name, vendor, scale, stock, buy price, MSRP",
|
||||
"whether a product sells well enough to justify a pricing or discontinuation decision",
|
||||
"creating a product, which it holds for the user's approval before writing",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: Everything the design foresees, minus what is not built. Ventes, Support,
|
||||
#: Approvisionnement and Recouvrement belong here as they arrive; the graph
|
||||
#: needs no change for them, only this list and a realm grant.
|
||||
SPECIALISTS: tuple[Specialist, ...] = (CATALOGUE,)
|
||||
|
||||
|
||||
def by_name(name: str) -> Specialist | None:
|
||||
"""The specialist called `name`, with its URL overridable per environment.
|
||||
|
||||
`ASSISTANT_URL_<NAME>` exists for running the orchestrator outside the
|
||||
cluster against a port-forwarded specialist. In the cluster nothing sets it
|
||||
and the Service DNS name below is used as written.
|
||||
"""
|
||||
found = next((s for s in SPECIALISTS if s.name == name), None)
|
||||
if found is None:
|
||||
return None
|
||||
override = os.environ.get(f"ASSISTANT_URL_{found.name.upper()}")
|
||||
return replace(found, url=override) if override else found
|
||||
|
||||
|
||||
def describe(specialist: Specialist) -> str:
|
||||
"""The tool description the routing model reads."""
|
||||
lines = [f"Consult the {specialist.name} agent. It serves {specialist.serves}."]
|
||||
lines.append("It can settle:")
|
||||
lines.extend(f"- {d}" for d in specialist.decides)
|
||||
lines.append(
|
||||
"Ask it a complete question in plain language, as you would ask a colleague. "
|
||||
"It answers; it does not hand back raw records."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user