Skip to content

Commit e0676fd

Browse files
committed
feat(cpex): harden hr-cpex chat demo — faithful rendering, --show-tools, ClientFactory
Iterate the A2A chat demo into a reliable, observable walkthrough: agent.py: - temperature=0 + hardened 'relay tool data verbatim' prompt so the model stops fabricating [REDACTED] for an allowed SSN (bob). - Drop the optional 'ssn' echo-back tool arg: models populated it inconsistently ('' / null / omitted) and a null tripped cpex's APL redact(args.ssn) type check -> 403. The SSN demo is response-side (include_ssn=true -> redact(result.ssn)); request-side args.ssn redaction stays covered by scenarios/01-bob-allow.sh. - include_ssn contract (tool-arg description + prompt): set true ONLY when the user explicitly asks for the SSN, so a plain 'look up compensation' no longer leaks it. - Attach a per-turn tool trace (name/args/status/result) to the A2A response message metadata for optional client display. chat.py: - Migrate off the deprecated A2AClient to ClientFactory; per-call identity headers are injected via a ClientCallInterceptor + ClientCallContext (the new client has no http_kwargs). Removes the deprecation warning. - Add --show-tools (default off): render the agent's cpex-governed tool calls and results, indented, tool name in color, result green (allow) / red (deny). Default model -> ollama/llama3:latest (tool-calls on plain ollama/, renders faithfully at temp=0, no 'thinking' latency). Makefile documents ollama/qwen3 (richer prose, slower) and llama3.2:3b (fastest, occasionally noisy) as alternatives. README + CHAT-WALKTHROUGH updated. Verified on a kind cluster: bob real SSN, eve [REDACTED], alice cpex 403; include_ssn only set when explicitly asked; no client deprecation warning. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent 014c85b commit e0676fd

6 files changed

Lines changed: 208 additions & 95 deletions

File tree

authbridge/demos/hr-cpex/Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ AGENT_SRC ?= $(CURDIR)/agent
5050
# Override per-env, e.g.: make deploy LLM_API_BASE=http://192.168.5.2:11434
5151
# MODEL — any litellm-routed model the host Ollama has pulled.
5252
LLM_API_BASE ?= http://host.docker.internal:11434
53-
MODEL ?= ollama/llama3.2:3b
53+
# llama3 (8B) tool-calls on the plain ollama/ provider and renders tool data
54+
# faithfully at temperature=0 (the agent sets temp=0) — no "thinking" overhead,
55+
# so it's notably faster than qwen3. Alternatives: ollama/qwen3 (a "thinking"
56+
# model — richer prose but slower); llama3.2:3b is fastest but a 3B model
57+
# occasionally editorializes/redacts fields in its prose.
58+
MODEL ?= ollama/llama3:latest
5459

5560
.PHONY: help build-images load-images apply deploy port-forward scenarios logs undeploy preflight ollama-check
5661

authbridge/demos/hr-cpex/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ install Kagenti or create a cluster. You need:
7272
to a registry.
7373
- kubectl, configured for the cluster.
7474
- An Ollama running on the host with a tool-capable model pulled
75-
(`ollama pull llama3.2:3b`) — only needed for the interactive chat;
75+
(`ollama pull llama3`) — only needed for the interactive chat;
7676
the `make scenarios` curl matrix does not use the LLM. Ollama must
7777
listen beyond loopback so cluster pods can reach it: set
7878
`OLLAMA_HOST=0.0.0.0` and restart Ollama. How a pod reaches the host
@@ -161,12 +161,12 @@ tokens and switches persona mid-conversation. The LLM and the tools live
161161
in the agent container; `chat.py` only sends `message/send` and renders
162162
the reply.
163163

164-
The agent itself runs an LLM (default `ollama/llama3.2:3b`). Make sure an
164+
The agent itself runs an LLM (default `ollama/llama3:latest`). Make sure an
165165
Ollama is running on the host with the model pulled and listening beyond
166166
loopback so the cluster can reach it:
167167

168168
```bash
169-
ollama pull llama3.2:3b
169+
ollama pull llama3
170170
OLLAMA_HOST=0.0.0.0 ollama serve # or, for the macOS app:
171171
# launchctl setenv OLLAMA_HOST 0.0.0.0 && relaunch Ollama
172172
```

authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ In a fourth terminal (the demo itself):
3535
```bash
3636
cd agent
3737
# one-time: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements-client.txt
38-
# Ollama must be running on the host with the model pulled (ollama pull llama3.2:3b).
38+
# Ollama must be running on the host with the model pulled (ollama pull llama3).
3939
python chat.py --persona bob # or: AGENT_URL=http://localhost:8082 python chat.py --persona bob
4040
```
4141

authbridge/demos/hr-cpex/agent/agent.py

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
from a2a.server.events import EventQueue
6363
from a2a.server.request_handlers import DefaultRequestHandler
6464
from a2a.server.tasks import InMemoryTaskStore
65-
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
65+
from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Message, Part, Role, TextPart
6666
from a2a.utils import new_agent_text_message
6767

6868
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper())
@@ -81,7 +81,7 @@
8181
MCP_PROXY = os.environ.get("MCP_PROXY", "http://localhost:8081")
8282
# litellm-routed model. Defaults to local Ollama (no API key). Override
8383
# with any LiteLLM-supported provider via MODEL + the provider's env.
84-
MODEL = os.environ.get("MODEL", "ollama/llama3.2:3b")
84+
MODEL = os.environ.get("MODEL", "ollama/llama3:latest")
8585
# Inference endpoint. For the ollama provider this is the native base
8686
# (litellm appends /api/...). Set to host.docker.internal in-cluster so
8787
# the agent reaches an Ollama running on the developer's laptop.
@@ -90,24 +90,37 @@
9090
AGENT_PUBLIC_URL = os.environ.get("AGENT_PUBLIC_URL", "http://hr-cpex-agent.cpex-demo:8080/")
9191

9292
# ---------------------------------------------------------------------------
93-
# Tools + prompt (lifted verbatim from the original chat.py — the gateway
94-
# enforcement story depends on these exact tool shapes, e.g. the `ssn`
95-
# echo-back arg that redaction strips).
93+
# Tools + prompt (lifted from the original chat.py). The SSN demo is
94+
# response-side: include_ssn=true → the gateway redacts result.ssn for callers
95+
# without view_ssn. The hardened prompt makes the model relay tool values
96+
# verbatim so it doesn't fabricate `[REDACTED]` for a real SSN.
9697
# ---------------------------------------------------------------------------
9798

9899
SYSTEM_PROMPT = (
99100
"You are an HR assistant for an HR copilot app. Help the user look up "
100101
"employee compensation, view directories, send emails, and similar "
101102
"tasks. Use the provided tools when needed. "
102103
"\n\n"
104+
"Only request data the user actually asked for: in particular, set "
105+
"get_compensation's `include_ssn` to true ONLY when the user explicitly "
106+
"asks to include/show the SSN. If the user just asks to look up "
107+
"compensation without mentioning the SSN, leave `include_ssn` false. "
108+
"\n\n"
109+
"CRITICAL — relay tool data verbatim: when you present a field, copy its "
110+
"value EXACTLY as it appears in the tool result. Never invent, mask, "
111+
"redact, or replace a value yourself. Only write `[REDACTED]` for a field "
112+
"if the tool result's value for that field is literally the string "
113+
"`[REDACTED]`; when the tool returns a real value (for example an actual "
114+
"social-security number), show that exact value unchanged. The gateway — "
115+
"not you — decides what to hide; your job is to relay precisely what it "
116+
"returned. "
117+
"\n\n"
103118
"How to interpret tool results: "
104119
"\n"
105-
" * If the tool returns a normal result, present the data to the "
106-
"user. If any field's value is the literal string `[REDACTED]`, "
107-
"show it as-is in your answer — that is the gateway's transparent "
108-
"enforcement marker that the field exists but is hidden for this "
109-
"caller. Do NOT apologize or refuse; just include the field with "
110-
"the value `[REDACTED]`. "
120+
" * Normal result: present the data, copying each value verbatim per the "
121+
"rule above. A field whose value is `[REDACTED]` is the gateway's "
122+
"transparent enforcement marker (the field exists but is hidden for this "
123+
"caller) — show it as-is; do NOT apologize or refuse. "
111124
"\n"
112125
" * If the tool returns an `error` envelope (a JSON-RPC error "
113126
"with a `code` and `message`), the gateway denied the call. "
@@ -135,18 +148,22 @@
135148
},
136149
"include_ssn": {
137150
"type": "boolean",
138-
"description": "Whether to include SSN in the response",
139-
"default": False,
140-
},
141-
"ssn": {
142-
"type": "string",
143151
"description": (
144-
"An echo-back of the employee's SSN if the caller "
145-
"claims to already know it — this is exactly the "
146-
"kind of field the gateway redacts when the "
147-
"caller lacks the necessary permission."
152+
"Whether to include the SSN in the response. Set to "
153+
"true ONLY when the user explicitly asks for the SSN "
154+
"(e.g. 'include the SSN'). If the user does not "
155+
"mention the SSN, omit this or set it to false."
148156
),
157+
"default": False,
149158
},
159+
# NB: no `ssn` request argument is exposed. The SSN demo is
160+
# response-side: set include_ssn=true and the gateway redacts
161+
# result.ssn for callers without view_ssn. An `ssn` echo-back
162+
# arg used to live here, but models populate it
163+
# inconsistently (""/null/omitted) and a null value trips the
164+
# APL redact(args.ssn) type check (expected Str) → 403. The
165+
# request-side args.ssn redaction is still exercised by the
166+
# deterministic curl matrix (scenarios/01-bob-allow.sh).
150167
},
151168
"required": ["employee_id"],
152169
},
@@ -352,12 +369,24 @@ def run_turn(
352369
user_token: str,
353370
client_token: str,
354371
session_id: str,
355-
) -> str:
356-
"""One user turn: LLM (direct) → tool calls (proxied/cpex) → LLM."""
372+
) -> tuple[str, list[dict[str, Any]]]:
373+
"""One user turn: LLM (direct) → tool calls (proxied/cpex) → LLM.
374+
375+
Returns (reply_text, tool_trace). tool_trace is a list of
376+
{name, args, status, text} records — one per cpex-governed tool call —
377+
which the executor attaches to the A2A response metadata so a client
378+
can optionally render what happened on the wire (see chat.py
379+
--show-tools). The trace is always collected; the client decides
380+
whether to display it."""
357381
messages = self._history(session_id)
358382
messages.append({"role": "user", "content": user_text})
359383

360-
completion_kwargs: dict[str, Any] = {}
384+
# temperature=0: deterministic decoding. The demo needs the model to
385+
# relay tool values verbatim (not creatively re-word or redact), so the
386+
# lowest-temperature, highest-probability completion is what we want —
387+
# it measurably reduces a small model's tendency to fabricate
388+
# `[REDACTED]` or editorialize fields.
389+
completion_kwargs: dict[str, Any] = {"temperature": 0}
361390
if LLM_API_BASE:
362391
completion_kwargs["api_base"] = LLM_API_BASE
363392

@@ -372,15 +401,16 @@ def run_turn(
372401
except Exception as e: # noqa: BLE001 — surface any LLM error to the user
373402
messages.pop()
374403
log.warning("LLM error: %s", e)
375-
return f"(LLM error: {e})"
404+
return f"(LLM error: {e})", []
376405

377406
assistant = response.choices[0].message
378407
if not assistant.tool_calls:
379408
text = assistant.content or "(no response)"
380409
messages.append({"role": "assistant", "content": text})
381-
return text
410+
return text, []
382411

383412
# Tool-call path: replay each call through cpex, then summarize.
413+
tool_trace: list[dict[str, Any]] = []
384414
messages.append(assistant.model_dump())
385415
for tc in assistant.tool_calls:
386416
fn = tc.function
@@ -406,14 +436,15 @@ def run_turn(
406436
tool_text = format_tool_response(status, data)
407437
log.info("tool result (session=%s, http=%s): %s", session_id, status, tool_text)
408438
messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_text})
439+
tool_trace.append({"name": fn.name, "args": args, "status": status, "text": tool_text})
409440

410441
try:
411442
final = litellm.completion(model=MODEL, messages=messages, **completion_kwargs)
412443
text = final.choices[0].message.content or ""
413444
except Exception as e: # noqa: BLE001
414445
text = f"(LLM error summarizing tool results: {e})"
415446
messages.append({"role": "assistant", "content": text})
416-
return text
447+
return text, tool_trace
417448

418449

419450
# ---------------------------------------------------------------------------
@@ -459,14 +490,26 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
459490
log.info("A2A turn (session=%s): %s", session_id, user_text)
460491
# litellm + httpx are synchronous; run off the event loop so we
461492
# don't block the server while the model and tools work.
462-
reply = await asyncio.to_thread(
493+
reply, tool_trace = await asyncio.to_thread(
463494
self._agent.run_turn,
464495
user_text,
465496
user_token=user_token,
466497
client_token=authorization,
467498
session_id=session_id,
468499
)
469-
await event_queue.enqueue_event(new_agent_text_message(reply, context.context_id, context.task_id))
500+
# Carry the per-turn tool trace in the response message metadata so a
501+
# client can optionally show what hit cpex/hr-mcp (see chat.py
502+
# --show-tools). new_agent_text_message has no metadata param, so build
503+
# the Message directly.
504+
message = Message(
505+
role=Role.agent,
506+
parts=[Part(root=TextPart(text=reply))],
507+
message_id=uuid.uuid4().hex,
508+
context_id=context.context_id,
509+
task_id=context.task_id,
510+
metadata={"tool_trace": tool_trace},
511+
)
512+
await event_queue.enqueue_event(message)
470513

471514
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
472515
# Single-shot turns; nothing long-running to cancel.

0 commit comments

Comments
 (0)