Skip to content

Commit f0c948f

Browse files
committed
ACP structured outputs experimental
1 parent 25f8e23 commit f0c948f

6 files changed

Lines changed: 727 additions & 19 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import asyncio
5+
import json
6+
import os
7+
import sys
8+
from pathlib import Path
9+
from typing import Any
10+
11+
from acp.exceptions import RequestError
12+
from acp.helpers import text_block
13+
from acp.schema import ClientCapabilities, FileSystemCapabilities, Implementation
14+
from acp.stdio import spawn_agent_process
15+
from jsonschema.validators import validator_for
16+
17+
SCHEMA: dict[str, Any] = {
18+
"type": "object",
19+
"properties": {
20+
"answer": {"type": "string"},
21+
},
22+
"required": ["answer"],
23+
"additionalProperties": False,
24+
}
25+
26+
27+
class SmokeClient:
28+
def __init__(self) -> None:
29+
self.notifications: list[dict[str, Any]] = []
30+
31+
async def session_update(
32+
self,
33+
session_id: str,
34+
update: Any,
35+
**kwargs: Any,
36+
) -> None:
37+
self.notifications.append({"session_id": session_id, "update": update})
38+
39+
40+
def _message_chunks(client: SmokeClient, session_id: str, *, start_index: int = 0) -> list[str]:
41+
chunks: list[str] = []
42+
for notification in client.notifications[start_index:]:
43+
if notification["session_id"] != session_id:
44+
continue
45+
update = notification["update"]
46+
update_type = getattr(update, "sessionUpdate", None)
47+
if update_type != "agent_message_chunk":
48+
continue
49+
content = getattr(update, "content", None)
50+
text = getattr(content, "text", None)
51+
if isinstance(text, str):
52+
chunks.append(text)
53+
return chunks
54+
55+
56+
def _validate_json_payload(text: str) -> None:
57+
payload = json.loads(text)
58+
validator_class = validator_for(SCHEMA)
59+
validator_class.check_schema(SCHEMA)
60+
validator_class(SCHEMA).validate(payload)
61+
62+
63+
async def _wait_for_agent_message_chunk(
64+
client: SmokeClient,
65+
session_id: str,
66+
*,
67+
start_index: int = 0,
68+
timeout: float = 2.0,
69+
) -> None:
70+
loop = asyncio.get_running_loop()
71+
deadline = loop.time() + timeout
72+
while loop.time() < deadline:
73+
for notification in client.notifications[start_index:]:
74+
if notification["session_id"] != session_id:
75+
continue
76+
update_type = getattr(notification["update"], "sessionUpdate", None)
77+
if update_type == "agent_message_chunk":
78+
return
79+
await asyncio.sleep(0.05)
80+
raise AssertionError(f"Expected agent_message_chunk for session {session_id}")
81+
82+
83+
async def run_smoke(args: argparse.Namespace) -> int:
84+
cmd = [
85+
sys.executable,
86+
"-m",
87+
"fast_agent.cli",
88+
"serve",
89+
"--transport",
90+
"acp",
91+
"--model",
92+
args.model,
93+
"--name",
94+
"acp-structured-output-smoke",
95+
"--no-permissions",
96+
]
97+
if args.config_path:
98+
cmd.extend(["--config-path", args.config_path])
99+
if args.env_dir:
100+
cmd.extend(["--env", args.env_dir])
101+
102+
client = SmokeClient()
103+
async with spawn_agent_process(
104+
lambda _: client,
105+
*cmd,
106+
env=os.environ.copy(),
107+
) as (connection, _process):
108+
try:
109+
init_response = await connection.initialize(
110+
protocol_version=1,
111+
client_capabilities=ClientCapabilities(
112+
fs=FileSystemCapabilities(read_text_file=False, write_text_file=False),
113+
terminal=False,
114+
),
115+
client_info=Implementation(name="acp-json-smoke", version="0.0.1"),
116+
)
117+
capability_meta = init_response.agent_capabilities.field_meta
118+
if not capability_meta:
119+
raise AssertionError("initialize response did not include agentCapabilities._meta")
120+
if capability_meta.get("co.huggingface", {}).get("structuredOutput") is not True:
121+
raise AssertionError("structuredOutput capability was not advertised")
122+
123+
session = await connection.new_session(mcp_servers=[], cwd=str(Path.cwd()))
124+
session_id = session.session_id
125+
if not session_id:
126+
raise AssertionError("session/new did not return a session id")
127+
128+
normal_start = len(client.notifications)
129+
normal_response = await connection.prompt(
130+
session_id=session_id,
131+
prompt=[text_block(args.normal_prompt)],
132+
)
133+
await _wait_for_agent_message_chunk(client, session_id, start_index=normal_start)
134+
normal_text = "".join(_message_chunks(client, session_id, start_index=normal_start))
135+
print(f"normal stopReason: {normal_response.stop_reason}")
136+
print(f"normal text: {normal_text}")
137+
138+
structured_meta: dict[str, Any] = {
139+
"co.huggingface": {
140+
"structuredOutput": {
141+
"schema": SCHEMA,
142+
"mode": "bestEffort",
143+
}
144+
}
145+
}
146+
structured_start = len(client.notifications)
147+
structured_response = await connection.prompt(
148+
session_id=session_id,
149+
prompt=[text_block(args.structured_prompt)],
150+
**structured_meta,
151+
)
152+
await _wait_for_agent_message_chunk(
153+
client,
154+
session_id,
155+
start_index=structured_start,
156+
)
157+
structured_text = "".join(
158+
_message_chunks(client, session_id, start_index=structured_start)
159+
)
160+
print(f"structured stopReason: {structured_response.stop_reason}")
161+
print(f"structured text: {structured_text}")
162+
163+
_validate_json_payload(structured_text)
164+
print("structured JSON validated against supplied schema")
165+
except RequestError as exc:
166+
print(f"ACP request failed: {exc}", file=sys.stderr)
167+
if exc.data is not None:
168+
print(json.dumps(exc.data, indent=2), file=sys.stderr)
169+
if exc.code == -32000:
170+
print(
171+
"Authentication is required for this model. Check the provider key "
172+
"env vars/secrets advertised above, or use --model passthrough for "
173+
"a credential-free transport smoke.",
174+
file=sys.stderr,
175+
)
176+
return 1
177+
178+
return 0
179+
180+
181+
def parse_args() -> argparse.Namespace:
182+
parser = argparse.ArgumentParser(
183+
description="Minimal ACP e2e smoke for co.huggingface.structuredOutput."
184+
)
185+
parser.add_argument("--model", default="passthrough")
186+
parser.add_argument("--config-path", help="Optional fastagent.config.yaml path")
187+
parser.add_argument("--env-dir", help="Optional fast-agent environment directory")
188+
parser.add_argument("--normal-prompt", default="hello from ACP smoke")
189+
parser.add_argument("--structured-prompt", default='{"answer":"ok"}')
190+
return parser.parse_args()
191+
192+
193+
def main() -> int:
194+
return asyncio.run(run_smoke(parse_args()))
195+
196+
197+
if __name__ == "__main__":
198+
raise SystemExit(main())

src/fast_agent/acp/server/agent_acp_server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,11 @@ async def initialize(
336336

337337
# Build our capabilities
338338
agent_capabilities = AgentCapabilities(
339+
field_meta={
340+
"co.huggingface": {
341+
"structuredOutput": True,
342+
}
343+
},
339344
prompt_capabilities=PromptCapabilities(
340345
image=True, # Support image content
341346
embedded_context=True, # Support embedded resources

0 commit comments

Comments
 (0)