Skip to content

Commit 15ed4fd

Browse files
feat(server): namespace scoping and control bindings (#203)
## Summary Adds the namespace-scoping data model and a single merged effective-controls contract that `initAgent`, `GET /agents/{name}/controls`, and `POST /evaluation` all share. - `namespace_key VARCHAR(255) NOT NULL DEFAULT 'default'` on `agents`, `controls`, `policies`, `agent_controls`, `agent_policies`, `policy_controls`, `control_bindings`. - Single-column uniqueness replaced by namespace-scoped composite uniqueness; association-table foreign keys are composite same-namespace foreign keys (Postgres-enforced). - New `control_bindings` table for attaching controls to opaque external targets. **One binding shape**: each row attaches one control to one target inside a namespace, uniqueness on `(namespace_key, target_type, target_id, control_id)`. The `enabled` flag is a soft toggle: disabled bindings are preserved but excluded from the effective set. - **Single merged resolver.** `ControlService.list_controls_for_agent` (and its runtime cousin) returns the de-duplicated union of the agent's direct controls, policy-derived controls, and (when target context is supplied) controls attached to that target via enabled bindings in the same namespace. `initAgent`, `GET /agents/{name}/controls?target_type=...&target_id=...`, and `POST /evaluation` all call into this resolver and return the same set for the same inputs. - Management API (`/control-bindings`): full CRUD plus idempotent natural-key upsert/delete (`PUT /control-bindings/by-key`, `POST /control-bindings/by-key:delete`). Cursor-based pagination on list with an opaque string cursor that round-trips cleanly to clients. Natural key is `(target_type, target_id, control_id)`. - `initAgent` accepts optional top-level `target_type` / `target_id`. Bindings can pre-exist the agent row, so a newly created agent registering with target context picks up pre-existing bindings on its first response (no second round-trip). - Python SDK target context is fixed per session: `init(target_type=..., target_id=...)` stores it on `state` and forwards it on the registration call and on every subsequent `/agents/{name}/controls` poll. The single existing policy refresh loop carries the merged set; there is no separate target-controls cache or refresh worker. - Session-target enforcement lives only on the session-bound entry point (`evaluate_controls`). `check_evaluation` and `check_evaluation_with_local` accept their own client and controls and run only the both-or-neither validation, so callers using those helpers are not implicitly bound to a previous `init()`. ## Namespace scoping Every effective-controls query filters every joined table on `namespace_key` explicitly. Composite FKs prevent cross-namespace writes; explicit query scoping prevents reads from spanning namespaces in the presence of namespace-collision attacks or compromised callers. Both layers are required. `_get_agent_or_404` requires `namespace_key`; an agent that exists only in another namespace surfaces as 404 (non-disclosing). Every agent endpoint that resolves an agent threads the namespace through: - `initAgent`, `GET /agents/{name}`, `GET /agents`, `GET /agents/{name}/evaluators`, `GET /agents/{name}/evaluators/{evaluator_name}`, `GET /agents/{name}/controls`, `PATCH /agents/{name}`. - All `/agents/{name}/policies*` routes (add / set / list / get / remove / remove-all / delete) and the corresponding `agent_policies` reads, writes, and deletes. - All `/agents/{name}/controls/{control_id}` routes (add / remove) and the corresponding `agent_controls` reads, writes, and deletes. - The list-cursor lookup is namespace-scoped so pagination cannot redirect through a foreign-namespace agent of the same name. `ControlService.get_active_control_or_404`, `list_controls_for_policy`, `add_control_to_agent`, and `remove_control_from_agent` accept `namespace_key` so the service layer is no longer namespace-blind on the migrated paths. Policy lookups in the agent association routes also filter by namespace. The initial release ships namespace plumbing at the schema level. Endpoints route through a single `get_namespace_key` dependency that always returns the default namespace; **overriding it is not supported yet** because `/controls` and `/policies` write endpoints still write under the default namespace, and an override here would create rows the existing endpoints cannot find. The initial release honors `get_namespace_key` on the effective-control paths and the migrated agent association paths; full `/controls` and `/policies` namespace-aware writes are follow-up work. Single-namespace deployments are preserved by the `'default'` server default. Plain `ix_agents_name`, `ix_policies_name`, and `ix_controls_name` (partial on `deleted_at IS NULL`) indexes preserve name-only lookup performance during the rollout window. The migration is reversible. `downgrade()` aborts with a clear error if cross-namespace duplicate names exist on `agents`, `policies`, or live `controls`, since restoring global single-column uniqueness would conflict. Soft-deleted control duplicates do not block downgrade. ## Initial-release contract ``` SDK init target is fixed per session. initAgent and GET /agents/{name}/controls return the effective controls for that session context. Runtime /evaluation uses the same merged resolution. Dynamic per-request target switching, inheritance, DAGs, and target-agent overrides remain out of scope. ``` The sessionful SDK path supports one active `init()` context. Callers that need multiple agents or targets in one process should use the lower-level helpers, or separate sessions once multi-session support exists. ## Notes on `control_bindings` - `ON DELETE CASCADE` on the parent control fires only on hard deletes. Soft-deleted controls (`deleted_at IS NOT NULL`) keep their bindings; the resolver excludes soft-deleted controls. - `delete_control` rejects with 409 when the control has active policy associations, direct agent associations, or active target bindings unless `force=true`, in which case all three classes of attachment are detached as part of the soft-delete lifecycle. - `updated_at` refreshes on every UPDATE via SQLAlchemy `onupdate`. - A `(namespace_key, control_id)` index covers the cascade path and `list_bindings(control_id=...)` filtering. - `idx_controls_namespace_name_active` is recognized as a name-conflict constraint, so concurrent duplicate-name races surface as 409, not 500. - Concurrent natural-key writes are safe. Both `ControlBindingsService.create_binding` and `upsert_by_natural_key` wrap their inserts in `begin_nested()` so a unique-constraint collision rolls back the SAVEPOINT only: the surrounding transaction is intact, and a caller that composed the service after another flush does not lose its prior writes. - Target bindings reject controls whose condition tree references agent-scoped evaluators (`agent_name:evaluator_name`). Bindings have no specific agent to validate the reference against, so accepting them would surface as a runtime evaluation failure on the first call rather than a clear 400 at attach time. New error code `CONTROL_BINDING_INCOMPATIBLE`. - Per-agent overrides and exemptions within a target are intentionally out of scope at this stage. Two forward paths are documented in code (migration comment plus `ControlBinding` docstring): - re-introduce an `agent_name` column with a partial-index pair and an `enabled`-aware most-specific-wins resolver; supports both per-agent additions and per-agent exemptions. - or merge target-bearing resolution with the existing `agent_controls` table at runtime; supports per-agent additions only, since `agent_controls` has no `enabled` flag. ## Generated client The TypeScript wrapper exposes the new `controlBindings` getter alongside the existing `agents`, `controls`, `evaluation`, `evaluators`, `observability`, `policies`, and `system` getters, so consumers using the public client can manage bindings without reaching into the generated internals. ## Out of scope (follow-up PRs) - Threading `get_namespace_key` through `/controls` and `/policies` write endpoints / services. - Auth-derived `get_namespace_key` resolution. - Namespace scoping for `control_versions` and `control_execution_events`. - Per-agent overrides and exemptions within a target. ## Test plan - [x] Migration: upgrade applies columns/constraints/indexes; `control_bindings` table created; downgrade restores originals; upgrade/downgrade round-trip; downgrade rejects cross-namespace duplicates on agents, policies, and live controls; allows soft-deleted duplicates. - [x] Isolation: same name across namespaces allowed; same name within a namespace rejected; soft-deleted control names reusable within a namespace; cross-namespace foreign keys rejected on every association table. - [x] `control_bindings` table: same control bindable to different targets; duplicate `(namespace, target_type, target_id, control_id)` rejected; cross-namespace `control_id` rejected; `ON DELETE CASCADE` on hard delete; bindings survive parent soft delete. - [x] Merged resolver: target bindings union with direct + policy controls; de-duplication when a control is attached through both paths; disabled binding excluded; soft-deleted controls excluded; namespace isolation; absence of target context omits bindings. - [x] `initAgent`: target params merge into the returned controls; newly created agent with target context picks up pre-existing bindings; partial target pair rejected (422). - [x] `GET /agents/{name}/controls?target_type=...&target_id=...` returns the same merged set as `initAgent`; partial target pair rejected (400); cross-namespace agent surfaces as 404. - [x] Cross-namespace agent association routes (read and delete) surface as 404, mirroring the effective-controls path. - [x] `/evaluation`: target context flows through the same merged resolver (target + agent + policy); 404 when the agent is not registered; partial target pair rejected (422). - [x] Management API: create / get / list (cursor pagination, opaque string cursor round-trips) / patch / delete; non-admin write rejection; natural-key upsert (idempotent, updates `enabled` and `updated_at`, handles concurrent insert race via SAVEPOINT); natural-key delete (idempotent); duplicate create surfaces as 409 with the surrounding transaction intact. - [x] Target binding rejects controls referencing agent-scoped evaluators with 400 `CONTROL_BINDING_INCOMPATIBLE`. - [x] `delete_control` lifecycle: refuses with 409 when policy / agent / binding attachments exist and `force=false`; detaches all three on `force=true`. - [x] Control-name conflict mapping: both legacy and new partial-unique index names trigger `CONTROL_NAME_CONFLICT` (409) instead of 500. - [x] SDK: `init(target_type=..., target_id=...)` stores session target and forwards it on registration and on every refresh; partial target pair rejected; per-call target overrides default from state and reject mismatches with the session target on the session-bound `evaluate_controls`; standalone `check_evaluation` / `check_evaluation_with_local` are not session-bound and run only the both-or-neither check. - [x] Full server test suite: 621 tests pass. - [x] SDK test suite: 541 tests pass. - [x] Models tests: 64 pass. - [x] TypeScript SDK tests: 8 pass. - [x] `make lint` clean. - [x] `make typecheck` clean. - [x] `make sdk-ts-generate-check` clean.
1 parent 09653e3 commit 15ed4fd

78 files changed

Lines changed: 7148 additions & 214 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎examples/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This directory contains runnable examples for Agent Control. Each example has it
1616
| Galileo Luna-2 | Toxicity detection and content moderation with Galileo Protect. | https://docs.agentcontrol.dev/examples/galileo-luna2 |
1717
| LangChain SQL Agent | Protect a SQL agent from dangerous queries with server-side controls. | https://docs.agentcontrol.dev/examples/langchain-sql |
1818
| Steer Action Demo | Banking transfer agent showcasing observe, deny, and steer actions. | https://docs.agentcontrol.dev/examples/steer-action-demo |
19+
| Target Context | Bind controls to opaque external targets (e.g. `env=prod`) and let the SDK pin one target per session. | https://docs.agentcontrol.dev/examples/target-context |
1920
| AWS Strands | Guardrails for AWS Strands agent workflows and tool calls. | https://docs.agentcontrol.dev/examples/aws-strands |
2021
| TypeScript SDK | Consumer-style TypeScript example using the published npm package. | https://docs.agentcontrol.dev/examples/typescript_sdk |
2122

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Target context demo
2+
3+
End-to-end walkthrough of target-bound controls and the V1 contract that
4+
target context is fixed for the lifetime of the SDK session.
5+
6+
## What this example shows
7+
8+
- `init(target_type=..., target_id=...)` returns the merged effective
9+
control set: the agent's direct attachments, policy-derived controls,
10+
and bindings for the supplied target, all in one response.
11+
- `@control()` decorator runs automatically against that merged set with
12+
no extra configuration.
13+
- `evaluate_controls(...)` defaults its target context from the session,
14+
so callers don't have to repeat themselves on every call.
15+
- A per-call target that disagrees with the session target is rejected
16+
with a clear `ValueError`: the SDK supports one target per session;
17+
re-init to change it.
18+
19+
The example uses `target_type="env"` and `target_id="prod"`. Environment
20+
is one common axis for target context; tenant-level isolation is handled
21+
separately by the namespace seam (`namespace_key`), so the example does
22+
not need to model multi-tenancy to demonstrate target binding.
23+
24+
## Quick run
25+
26+
```bash
27+
# From repo root.
28+
make server-run
29+
30+
# In a separate shell.
31+
uv run python examples/target_context_demo/setup_controls.py
32+
uv run python examples/target_context_demo/demo_agent.py
33+
```
34+
35+
## What you should see
36+
37+
```
38+
=== 1. init() with target context ===
39+
Effective controls (env=prod): ['block-pii-output', 'block-prod-restricted-input']
40+
41+
=== 2. @control() runs against the merged set ===
42+
Safe call -> 'You said: hello, target context'
43+
Pre-stage block (binding): block-prod-restricted-input
44+
Post-stage block (direct): block-pii-output
45+
46+
=== 3. evaluate_controls() defaults target from session ===
47+
is_safe=True, confidence=1.00
48+
49+
=== 4. per-call target that disagrees with the session is rejected ===
50+
Rejected as expected: Per-call target context must match the target
51+
context fixed at init() time. The SDK supports one target per session
52+
(including no-target sessions); re-init to change it.
53+
```
54+
55+
## Layout the setup script provisions
56+
57+
```
58+
agent: demo-target-bot
59+
+ direct attachment: block-pii-output (always on; SSN regex on output)
60+
61+
control_bindings:
62+
+ (env, prod) -> block-prod-restricted-input (only on env=prod; rejects sudo / DROP TABLE / rm -rf)
63+
```
64+
65+
When `init()` is called with `target_type="env", target_id="prod"`, the
66+
server merges these into a single set of two controls. Re-running with
67+
a different `env` value or no target context drops the prod-bound
68+
control from the response.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env python3
2+
"""Demo agent: target context fixed at SDK init().
3+
4+
Walks through every touchpoint of the V1 contract:
5+
6+
1. ``init(target_type=..., target_id=...)`` returns the merged effective
7+
set (direct attachments + policy-derived + target bindings).
8+
2. ``@control()`` decorator runs automatically against that merged set.
9+
3. ``evaluate_controls(...)`` defaults its target context from the
10+
session, so callers don't need to repeat themselves.
11+
4. A per-call target that disagrees with the session target is rejected
12+
with a clear ``ValueError``: the SDK supports one target per session.
13+
14+
Prerequisites:
15+
- Agent Control server running.
16+
- ``setup_controls.py`` executed first to provision the agent + bindings.
17+
18+
Usage:
19+
uv run python examples/target_context_demo/demo_agent.py
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import asyncio
25+
import os
26+
import sys
27+
28+
# Add the SDK to path for development.
29+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../sdks/python/src"))
30+
31+
import agent_control
32+
from agent_control import ControlViolationError, control
33+
34+
AGENT_NAME = "demo-target-bot"
35+
SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000")
36+
37+
38+
def simulate_llm_response(prompt: str) -> str:
39+
"""Stand-in for an LLM. Crafts an output that triggers the PII control
40+
when the prompt asks about a customer record."""
41+
if "customer record" in prompt.lower():
42+
return "Customer 123-45-6789 paid invoice INV-001."
43+
return f"You said: {prompt}"
44+
45+
46+
@control()
47+
async def chat(message: str) -> str:
48+
return simulate_llm_response(message)
49+
50+
51+
def show_effective_controls(label: str) -> None:
52+
controls = agent_control.get_server_controls() or []
53+
names = [c["name"] for c in controls]
54+
print(f" {label}: {sorted(names) or '<none>'}")
55+
56+
57+
async def phase_1_init_with_target() -> None:
58+
print("\n=== 1. init() with target context ===")
59+
agent_control.init(
60+
agent_name=AGENT_NAME,
61+
server_url=SERVER_URL,
62+
target_type="env",
63+
target_id="prod",
64+
# Disable the periodic refresh; one fetch at init is enough for the demo.
65+
policy_refresh_interval_seconds=0,
66+
)
67+
show_effective_controls("Effective controls (env=prod)")
68+
# Expected: ['block-pii-output', 'block-prod-restricted-input'] - the
69+
# agent's direct attachment plus the target-bound control are merged.
70+
71+
72+
async def phase_2_decorator_runs_against_merged_set() -> None:
73+
print("\n=== 2. @control() runs against the merged set ===")
74+
75+
# Safe input + safe output: passes both controls.
76+
result = await chat("hello, target context")
77+
print(f" Safe call -> {result!r}")
78+
79+
# Input contains a prod-restricted keyword: pre-stage block via the
80+
# binding-derived control.
81+
try:
82+
await chat("please run sudo apt-get install something")
83+
except ControlViolationError as exc:
84+
print(f" Pre-stage block (binding): {exc.control_name}")
85+
86+
# Input is harmless but the simulated LLM will emit an SSN-like string,
87+
# tripping the post-stage PII control attached directly to the agent.
88+
try:
89+
await chat("show me a sample customer record")
90+
except ControlViolationError as exc:
91+
print(f" Post-stage block (direct): {exc.control_name}")
92+
93+
94+
async def phase_3_evaluate_controls_defaults_from_session() -> None:
95+
print("\n=== 3. evaluate_controls() defaults target from session ===")
96+
# No target args here — the SDK uses the session target set at init().
97+
result = await agent_control.evaluate_controls(
98+
step_name="chat",
99+
step_type="llm",
100+
stage="pre",
101+
input="hello, no per-call target",
102+
agent_name=AGENT_NAME,
103+
)
104+
print(f" is_safe={result.is_safe}, confidence={result.confidence:.2f}")
105+
106+
107+
async def phase_4_per_call_mismatch_rejected() -> None:
108+
print("\n=== 4. per-call target that disagrees with the session is rejected ===")
109+
try:
110+
await agent_control.evaluate_controls(
111+
step_name="chat",
112+
step_type="llm",
113+
stage="pre",
114+
input="hello",
115+
agent_name=AGENT_NAME,
116+
target_type="env",
117+
target_id="staging", # session target is "prod"
118+
)
119+
except ValueError as exc:
120+
print(f" Rejected as expected: {exc}")
121+
122+
123+
async def main() -> None:
124+
try:
125+
await phase_1_init_with_target()
126+
await phase_2_decorator_runs_against_merged_set()
127+
await phase_3_evaluate_controls_defaults_from_session()
128+
await phase_4_per_call_mismatch_rejected()
129+
finally:
130+
await agent_control.ashutdown()
131+
132+
133+
if __name__ == "__main__":
134+
asyncio.run(main())
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env python3
2+
"""Set up the target-context demo: agent + two controls + one binding.
3+
4+
Layout:
5+
6+
- ``block-pii-output`` is attached directly to the agent. It applies on
7+
every call regardless of target context.
8+
- ``block-prod-restricted-input`` is bound to ``(env, prod)`` via a
9+
control binding. It applies only when the SDK's session target is
10+
``(env, prod)`` and is omitted from the merged set otherwise.
11+
12+
When the demo agent calls ``init(target_type="env", target_id="prod")``,
13+
the server returns the de-duplicated union: both controls. Re-init for a
14+
different ``env`` value (or no target) drops the prod-bound control from
15+
the returned set.
16+
17+
Prerequisites:
18+
- Agent Control server running at AGENT_CONTROL_URL (default localhost:8000).
19+
20+
Usage:
21+
uv run python examples/target_context_demo/setup_controls.py
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import asyncio
27+
import os
28+
import sys
29+
from typing import Any
30+
31+
# Add the SDK to path for development.
32+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../sdks/python/src"))
33+
34+
from agent_control import AgentControlClient
35+
36+
AGENT_NAME = "demo-target-bot"
37+
SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000")
38+
39+
PII_CONTROL_NAME = "block-pii-output"
40+
PROD_CONTROL_NAME = "block-prod-restricted-input"
41+
42+
PII_CONTROL: dict[str, Any] = {
43+
"description": "Block SSN-like patterns in agent output (always on).",
44+
"enabled": True,
45+
"execution": "server",
46+
"scope": {"step_types": ["llm"], "stages": ["post"]},
47+
"condition": {
48+
"selector": {"path": "output"},
49+
"evaluator": {
50+
"name": "regex",
51+
"config": {"pattern": r"\b\d{3}-\d{2}-\d{4}\b", "flags": []},
52+
},
53+
},
54+
"action": {"decision": "deny"},
55+
"tags": ["pii", "agent-attached"],
56+
}
57+
58+
PROD_CONTROL: dict[str, Any] = {
59+
"description": "Reject restricted input keywords on prod.",
60+
"enabled": True,
61+
"execution": "server",
62+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
63+
"condition": {
64+
"selector": {"path": "input"},
65+
"evaluator": {
66+
"name": "list",
67+
"config": {
68+
"values": ["DROP TABLE", "rm -rf", "sudo"],
69+
"logic": "any",
70+
"match_on": "match",
71+
"match_mode": "contains",
72+
"case_sensitive": False,
73+
},
74+
},
75+
},
76+
"action": {"decision": "deny"},
77+
"tags": ["env-bound", "prod-only"],
78+
}
79+
80+
81+
async def _create_or_get_control(
82+
client: AgentControlClient, name: str, definition: dict[str, Any]
83+
) -> int:
84+
"""Create a control by name; return its ID. Surfaces 409 cleanly."""
85+
response = await client.http_client.put(
86+
"/api/v1/controls",
87+
json={"name": name, "data": definition},
88+
)
89+
if response.status_code == 409:
90+
# Control already exists from a previous run — look it up.
91+
page = await client.http_client.get(
92+
"/api/v1/controls", params={"name": name}
93+
)
94+
page.raise_for_status()
95+
for entry in page.json().get("controls", []):
96+
if entry.get("name") == name:
97+
return int(entry["id"])
98+
raise RuntimeError(f"Control {name!r} reported as existing but not found.")
99+
response.raise_for_status()
100+
return int(response.json()["control_id"])
101+
102+
103+
async def main() -> None:
104+
print(f"Server: {SERVER_URL}")
105+
async with AgentControlClient(base_url=SERVER_URL) as client:
106+
# 1. Register the agent (no target context here — registration is
107+
# independent of target binding).
108+
register = await client.http_client.post(
109+
"/api/v1/agents/initAgent",
110+
json={
111+
"agent": {
112+
"agent_name": AGENT_NAME,
113+
"agent_description": "Demo agent for target context.",
114+
},
115+
"steps": [],
116+
},
117+
)
118+
register.raise_for_status()
119+
print(f" ✓ Registered agent: {AGENT_NAME}")
120+
121+
# 2. Create both controls.
122+
pii_id = await _create_or_get_control(client, PII_CONTROL_NAME, PII_CONTROL)
123+
print(f" ✓ Control {PII_CONTROL_NAME!r} -> id={pii_id}")
124+
prod_id = await _create_or_get_control(
125+
client, PROD_CONTROL_NAME, PROD_CONTROL
126+
)
127+
print(f" ✓ Control {PROD_CONTROL_NAME!r} -> id={prod_id}")
128+
129+
# 3. Attach the PII control directly to the agent (always on).
130+
attach = await client.http_client.post(
131+
f"/api/v1/agents/{AGENT_NAME}/controls/{pii_id}"
132+
)
133+
attach.raise_for_status()
134+
print(f" ✓ Attached {PII_CONTROL_NAME!r} directly to {AGENT_NAME}")
135+
136+
# 4. Bind the prod-only control to (env, prod) via the natural-key
137+
# upsert endpoint. Idempotent: re-running the script is safe.
138+
upsert = await client.http_client.put(
139+
"/api/v1/control-bindings/by-key",
140+
json={
141+
"target_type": "env",
142+
"target_id": "prod",
143+
"control_id": prod_id,
144+
"enabled": True,
145+
},
146+
)
147+
upsert.raise_for_status()
148+
body = upsert.json()
149+
action = "Created" if body.get("created") else "Updated"
150+
print(
151+
f" ✓ {action} binding "
152+
f"({PROD_CONTROL_NAME!r} -> env=prod) id={body['binding_id']}"
153+
)
154+
155+
print(
156+
"\nReady. Run the demo:\n"
157+
" uv run python examples/target_context_demo/demo_agent.py"
158+
)
159+
160+
161+
if __name__ == "__main__":
162+
asyncio.run(main())

0 commit comments

Comments
 (0)