Skip to content

Commit 971af87

Browse files
authored
feature: 支持tool流式输出 (#66)
1 parent 43801a1 commit 971af87

26 files changed

Lines changed: 2033 additions & 49 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME
2+
TRPC_AGENT_API_KEY=your-api-key
3+
TRPC_AGENT_BASE_URL=your-base-url
4+
TRPC_AGENT_MODEL_NAME=your-model-name
5+
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Streaming Progress Tool
2+
3+
This example shows how to expose a **long-running tool that streams progress
4+
events to the user in real time**, using `StreamingProgressTool`.
5+
6+
The wrapped function is an `async def` generator (`async def fn(...): yield ...`).
7+
Every `yield` is surfaced to the runner as a `partial=True` Event tagged with
8+
`custom_metadata={"tool_progress": True, ...}`. The **last** yielded value is
9+
*also* the final `function_response` returned to the LLM.
10+
11+
```text
12+
yield progress_1 --> partial Event (live)
13+
yield progress_2 --> partial Event (live)
14+
yield progress_3 --> partial Event (live) AND final function_response
15+
```
16+
17+
This is different from the other two streaming-ish tools shipped with the SDK:
18+
19+
| Class | What gets streamed |
20+
| --------------------------- | --------------------------------------------------- |
21+
| `StreamingFunctionTool` | The *arguments* the LLM is generating for the call. |
22+
| `LongRunningFunctionTool` | Nothing intermediate; just marks the call as slow. |
23+
| **`StreamingProgressTool`** | The tool's *own* execution progress. |
24+
25+
## Run
26+
27+
```bash
28+
cd examples/llmagent_with_streaming_progress_tool
29+
cp ../mcp_tools/.env .env # or write your own
30+
# edit .env to set TRPC_AGENT_API_KEY / BASE_URL / MODEL_NAME
31+
python run_agent.py
32+
```
33+
34+
Expected output (abridged):
35+
36+
```
37+
User: Please crawl https://example.com and fetch the first 5 pages.
38+
[crawl_site] ⏳ {'status': 'started', 'url': 'https://example.com', 'max_pages': 5}
39+
[crawl_site] ⏳ {'status': 'fetched', 'page': 1, 'total': 5, ...}
40+
[crawl_site] ⏳ {'status': 'fetched', 'page': 2, 'total': 5, ...}
41+
...
42+
[tool-result] crawl_site → {'status': 'done', 'url': '...', 'pages_fetched': 5, ...}
43+
Assistant: I crawled example.com and fetched 5 pages. ...
44+
```
45+
46+
## How to consume progress events on the client side
47+
48+
Filter on `event.partial` + `custom_metadata.tool_progress` to detect a
49+
progress chunk. The raw value the tool yielded is available in
50+
`custom_metadata['payload']` (for `dict`/`BaseModel` yields) and as a JSON
51+
string in `event.content.parts[0].text` for plain-text consumers.
52+
53+
```python
54+
async for event in runner.run_async(...):
55+
meta = event.custom_metadata or {}
56+
if event.partial and meta.get("tool_progress"):
57+
print(meta["tool_name"], meta.get("payload") or event.get_text())
58+
continue
59+
# ...handle final events as usual
60+
```
61+
62+
Notes:
63+
- Progress events are NOT persisted into session history (they are partial).
64+
- The LLM only ever sees the **last** yielded value as the tool response.
65+
- If a batch contains a progress-streaming tool, the framework forces
66+
sequential tool execution to keep interim events in deterministic order,
67+
even if the agent has `parallel_tool_calls=True`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Agent that uses StreamingProgressTool for a long-running task."""
7+
8+
from trpc_agent_sdk.agents import LlmAgent
9+
from trpc_agent_sdk.models import LLMModel
10+
from trpc_agent_sdk.models import OpenAIModel
11+
from trpc_agent_sdk.tools import StreamingProgressTool
12+
from trpc_agent_sdk.types import GenerateContentConfig
13+
14+
from .config import get_model_config
15+
from .prompts import INSTRUCTION
16+
from .tools import crawl_site
17+
18+
19+
def _create_model() -> LLMModel:
20+
api_key, url, model_name = get_model_config()
21+
return OpenAIModel(model_name=model_name, api_key=api_key, base_url=url)
22+
23+
24+
def create_agent() -> LlmAgent:
25+
"""Build the agent. ``crawl_site`` is wrapped in
26+
``StreamingProgressTool(skip_summarization=True)`` so that:
27+
28+
1. Every ``yield`` becomes a partial Event the caller renders live.
29+
2. The last ``yield`` is also the final ``function_response`` event –
30+
persisted to the session as the canonical record of this turn.
31+
3. ``skip_summarization=True`` makes :class:`LlmAgent` exit the
32+
conversation loop immediately after the tool returns, so the LLM
33+
is **not** asked to re-summarise the streamed output (which the
34+
user has already seen).
35+
"""
36+
crawl_tool = StreamingProgressTool(crawl_site, skip_summarization=True)
37+
38+
return LlmAgent(
39+
name="streaming_crawler",
40+
description="Crawls a site step-by-step and streams progress to the user.",
41+
model=_create_model(),
42+
instruction=INSTRUCTION,
43+
tools=[crawl_tool],
44+
generate_content_config=GenerateContentConfig(
45+
temperature=0.3,
46+
max_output_tokens=1000,
47+
),
48+
)
49+
50+
51+
root_agent = create_agent()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Agent config module."""
7+
8+
import os
9+
10+
11+
def get_model_config() -> tuple[str, str, str]:
12+
"""Get model config from environment variables."""
13+
api_key = os.getenv('TRPC_AGENT_API_KEY', '')
14+
url = os.getenv('TRPC_AGENT_BASE_URL', '')
15+
model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '')
16+
if not api_key or not url or not model_name:
17+
raise ValueError("TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, and TRPC_AGENT_MODEL_NAME "
18+
"must be set in environment variables (e.g. via a .env file).")
19+
return api_key, url, model_name
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Prompts for the streaming-progress tool demo."""
7+
8+
INSTRUCTION = (
9+
"You are a helpful crawling assistant. When the user asks you to crawl, fetch, "
10+
"or inspect a website, ALWAYS call the `crawl_site` tool. Pass a sensible "
11+
"`max_pages` (default to 5 if unspecified). After the tool finishes, "
12+
"summarise what was fetched in 1-2 sentences in the user's language.")
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""A long-running tool that streams progress events to the user.
7+
8+
The tool simulates a multi-step site crawl. Each step yields a structured
9+
progress payload that the framework surfaces as a partial Event in real time.
10+
The **last** yielded value is also the final tool response fed back to the LLM.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import asyncio
16+
import random
17+
from typing import AsyncIterator
18+
19+
20+
async def crawl_site(url: str, max_pages: int = 5) -> AsyncIterator[dict]:
21+
"""Crawl ``url`` and stream progress for every page fetched.
22+
23+
Use this for long-running fetches where the user benefits from seeing
24+
incremental progress instead of staring at a spinner.
25+
26+
Args:
27+
url: The site URL to crawl (any string for demo purposes).
28+
max_pages: How many pages to simulate fetching. Defaults to 5.
29+
30+
Yields:
31+
dict: One progress payload per step. The final payload is also the
32+
return value the LLM sees.
33+
"""
34+
yield {"status": "started", "url": url, "max_pages": max_pages}
35+
36+
fetched_titles: list[str] = []
37+
for page_index in range(1, max_pages + 1):
38+
# Simulate variable per-page latency so the streaming is observable.
39+
await asyncio.sleep(random.uniform(0.4, 1.2))
40+
title = f"{url} - page {page_index}"
41+
fetched_titles.append(title)
42+
yield {
43+
"status": "fetched",
44+
"page": page_index,
45+
"total": max_pages,
46+
"title": title,
47+
"progress": round(page_index / max_pages, 2),
48+
}
49+
50+
yield {
51+
"status": "done",
52+
"url": url,
53+
"pages_fetched": len(fetched_titles),
54+
"titles": fetched_titles,
55+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Demo: streaming progress events from a long-running tool.
7+
8+
Run with::
9+
10+
cd examples/llmagent_with_streaming_progress_tool
11+
python run_agent.py
12+
13+
Make sure ``TRPC_AGENT_API_KEY``, ``TRPC_AGENT_BASE_URL`` and
14+
``TRPC_AGENT_MODEL_NAME`` are set in your environment or .env file.
15+
"""
16+
17+
import asyncio
18+
import sys
19+
import uuid
20+
from pathlib import Path
21+
22+
from dotenv import load_dotenv
23+
from trpc_agent_sdk.runners import Runner
24+
from trpc_agent_sdk.sessions import InMemorySessionService
25+
from trpc_agent_sdk.types import Content
26+
from trpc_agent_sdk.types import Part
27+
28+
load_dotenv()
29+
30+
sys.path.append(str(Path(__file__).parent))
31+
32+
33+
async def run_streaming_progress_demo() -> None:
34+
"""Issue one query and pretty-print every event surfaced by the runner.
35+
36+
Three event kinds matter here:
37+
1. ``event.partial`` with ``custom_metadata.tool_progress`` → a *progress*
38+
chunk from the streaming tool. Print it live; do NOT treat it as a
39+
tool response.
40+
2. ``event.partial`` text from the LLM (no ``tool_progress`` marker) →
41+
streaming model output.
42+
3. ``partial=False`` events with a ``function_response`` part → the final
43+
tool result; with a ``text`` part → the model's final reply.
44+
"""
45+
46+
app_name = "streaming_progress_demo"
47+
from agent.agent import root_agent
48+
49+
session_service = InMemorySessionService()
50+
runner = Runner(app_name=app_name, agent=root_agent, session_service=session_service)
51+
52+
user_id = "demo_user"
53+
session_id = str(uuid.uuid4())
54+
await session_service.create_session(app_name=app_name, user_id=user_id, session_id=session_id)
55+
56+
query = "Please crawl https://example.com and fetch the first 5 pages."
57+
print("=" * 60)
58+
print(f"User: {query}")
59+
print("=" * 60)
60+
61+
user_content = Content(parts=[Part.from_text(text=query)])
62+
63+
async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=user_content):
64+
meta = event.custom_metadata or {}
65+
66+
# --- 1. Tool progress (partial, comes from StreamingProgressTool) ---
67+
if event.partial and meta.get("tool_progress"):
68+
payload = meta.get("payload")
69+
tool_name = meta.get("tool_name", "?")
70+
print(f"[{tool_name}] ⏳ {payload if payload is not None else event.get_text()}")
71+
continue
72+
73+
if not event.content or not event.content.parts:
74+
continue
75+
76+
# --- 2. Streaming LLM text ---
77+
if event.partial:
78+
for part in event.content.parts:
79+
if part.text:
80+
print(part.text, end="", flush=True)
81+
continue
82+
83+
# --- 3. Final events ---
84+
for part in event.content.parts:
85+
if part.function_call:
86+
print(f"\n[tool-call] {part.function_call.name}({part.function_call.args})")
87+
elif part.function_response:
88+
print(f"\n[tool-result] {part.function_response.name} → "
89+
f"{part.function_response.response}")
90+
elif part.text:
91+
print(f"\nAssistant: {part.text}")
92+
93+
print("\n" + "-" * 60)
94+
95+
96+
if __name__ == "__main__":
97+
print("""
98+
+--------------------------------------------------------------+
99+
| StreamingProgressTool Demo (long-running tool) |
100+
| |
101+
| Watch the tool yield progress events live, then the LLM |
102+
| summarises the final result. |
103+
+--------------------------------------------------------------+
104+
""")
105+
asyncio.run(run_streaming_progress_demo())

0 commit comments

Comments
 (0)