Skip to content

Commit 1907281

Browse files
committed
Restructure: split cli.py and agent/loop.py, consolidate the MCP layer
P1: pion/cli.py (1049 lines) becomes the pion/cli/ package — app.py (typer commands), bootstrap.py (_async_main assembly), profiles.py (connection-profile flows), plain.py (REPL), with __init__.py re-exporting the full public API and __main__.py keeping "python -m pion.cli mcp" working. Names that tests monkeypatch on the pion.cli module are resolved through the package namespace at call time. P2: pion/agent/loop.py (776 lines) splits into loop.py (state machine and public config types), streaming.py (LLM event stream handling) and tool_execution.py (tool-call preparation/execution/finalization). No public names moved; agent.py and tests import unchanged. P3: pion/mcp.py and pion/mcp_server.py consolidate into the pion/mcp/ package (client.py + sandbox_server.py), with __init__.py re-exporting the client API. P4: the unguarded module-level tool instances (DEFAULT_TOOLS & co.) move to pion/tools/legacy.py with a deprecation note, re-exported for compatibility; build_default_tools(runtime) is the single supported entry point. Pure moves throughout — no behavior changes. Full suite stays green (288 passed, 2 skipped), entry points and the stdio MCP sandbox server verified.
1 parent a4ded22 commit 1907281

17 files changed

Lines changed: 1754 additions & 1552 deletions

pion/agent/loop.py

Lines changed: 10 additions & 484 deletions
Large diffs are not rendered by default.

pion/agent/streaming.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Assistant response streaming — LLM event stream -> AgentEvent conversion.
2+
3+
Split out of `loop.py` (pure move). `_stream_assistant_response` streams one
4+
assistant response, updating the context as partials arrive and emitting
5+
`message_start`/`message_update`/`message_end` events.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
from typing import TYPE_CHECKING, Optional
12+
13+
from ..llm.event_stream import AssistantMessageEventStream, StreamOptions
14+
from ..llm.types import AssistantMessage, Context, Tool, sanitize_message
15+
from .events import AgentEvent
16+
from .loop import _maybe_await
17+
18+
if TYPE_CHECKING:
19+
from .loop import AgentContext, AgentEventSink, AgentLoopConfig, StreamFn
20+
21+
22+
async def _stream_assistant_response(
23+
context: AgentContext,
24+
config: AgentLoopConfig,
25+
abort: Optional[asyncio.Event],
26+
emit: AgentEventSink,
27+
stream_fn: StreamFn,
28+
) -> AssistantMessage:
29+
"""Stream one assistant response, updating the context as partials arrive."""
30+
messages = context.messages
31+
if config.transform_context is not None:
32+
messages = await _maybe_await(config.transform_context(messages))
33+
34+
llm_messages = await _maybe_await(config.convert_to_llm(messages))
35+
36+
llm_context = Context(
37+
system_prompt=context.system_prompt,
38+
messages=llm_messages,
39+
tools=[
40+
Tool(name=tool.name, description=tool.description, parameters=tool.parameters)
41+
for tool in context.tools
42+
],
43+
)
44+
45+
options = StreamOptions(api_key=config.api_key, abort=abort)
46+
response: AssistantMessageEventStream = await _maybe_await(
47+
stream_fn(config.model, llm_context, options)
48+
)
49+
50+
partial: Optional[AssistantMessage] = None
51+
added_partial = False
52+
53+
async for event in response:
54+
if event.type == "start":
55+
partial = event.partial if event.partial is not None else AssistantMessage()
56+
context.messages.append(partial)
57+
added_partial = True
58+
await emit(AgentEvent(type="message_start", message=partial))
59+
elif event.type in (
60+
"text_start",
61+
"text_delta",
62+
"text_end",
63+
"thinking_start",
64+
"thinking_delta",
65+
"thinking_end",
66+
"toolcall_start",
67+
"toolcall_delta",
68+
"toolcall_end",
69+
):
70+
if partial is not None:
71+
if event.partial is not None:
72+
partial = event.partial
73+
context.messages[-1] = partial
74+
await emit(
75+
AgentEvent(type="message_update", assistant_event=event, message=partial)
76+
)
77+
elif event.type in ("done", "error"):
78+
final_message = await response.result()
79+
# Scrub lone surrogates coming from the provider (unpaired
80+
# \uXXXX escapes) before the message enters the context.
81+
final_message = sanitize_message(final_message)
82+
if added_partial:
83+
context.messages[-1] = final_message
84+
else:
85+
context.messages.append(final_message)
86+
await emit(AgentEvent(type="message_start", message=final_message))
87+
await emit(AgentEvent(type="message_end", message=final_message))
88+
return final_message
89+
90+
# Stream ended without done/error: result() raises (contract violation).
91+
final_message = await response.result()
92+
final_message = sanitize_message(final_message)
93+
if added_partial:
94+
context.messages[-1] = final_message
95+
else:
96+
context.messages.append(final_message)
97+
await emit(AgentEvent(type="message_start", message=final_message))
98+
await emit(AgentEvent(type="message_end", message=final_message))
99+
return final_message

0 commit comments

Comments
 (0)