fluxos-agent 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluxos-agent
3
+ Version: 2.0.0
4
+ Summary: Stateful agent runtime with tool execution and event streaming
5
+ Author-email: fluxos contributors <dev@fluxtopus.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: httpx>=0.27.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
16
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
17
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
18
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
19
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
20
+
21
+ # fluxos-agent
22
+
23
+ Python port of `pi-mono/packages/agent` [https://github.com/badlogic/pi-mono/tree/main/packages/agent].
24
+
25
+ `fluxos-agent` provides:
26
+ - Stateful agent runtime (`Agent`) with prompt/continue flows
27
+ - Agent loop primitives (`agent_loop`, `agent_loop_continue`)
28
+ - Tool execution with streaming update events
29
+ - Steering and follow-up queues
30
+ - Optional proxy stream adapter (`stream_proxy`)
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install -e packages/fluxos-agent
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ import asyncio
42
+
43
+ from flux_agent import Agent, AgentOptions
44
+ from flux_agent.types import (
45
+ AssistantDoneEvent,
46
+ AssistantMessage,
47
+ Context,
48
+ Model,
49
+ TextContent,
50
+ Usage,
51
+ UsageCost,
52
+ )
53
+ from flux_agent.event_stream import EventStream
54
+
55
+
56
+ class MockAssistantStream(EventStream):
57
+ def __init__(self):
58
+ super().__init__(
59
+ lambda event: event.type in {"done", "error"},
60
+ lambda event: event.message if event.type == "done" else event.error,
61
+ )
62
+
63
+
64
+ def stream_fn(model: Model, context: Context, options):
65
+ stream = MockAssistantStream()
66
+ message = AssistantMessage(
67
+ content=[TextContent(text="Hello from Python agent")],
68
+ api=model.api,
69
+ provider=model.provider,
70
+ model=model.id,
71
+ usage=Usage(cost=UsageCost()),
72
+ stop_reason="stop",
73
+ )
74
+ stream.push(AssistantDoneEvent(reason="stop", message=message))
75
+ return stream
76
+
77
+
78
+ async def main():
79
+ agent = Agent(
80
+ AgentOptions(
81
+ stream_fn=stream_fn,
82
+ initial_state={
83
+ "system_prompt": "You are helpful.",
84
+ "model": Model(id="mock", name="mock", api="mock", provider="mock"),
85
+ },
86
+ )
87
+ )
88
+
89
+ agent.subscribe(lambda event: print(event.type))
90
+ await agent.prompt("Hello")
91
+
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ ## Testing
97
+
98
+ ```bash
99
+ cd packages/fluxos-agent
100
+ pip install -e ".[dev]"
101
+ pytest tests/unit -v
102
+ ```
103
+
104
+ Run full suite (unit + e2e scaffolding):
105
+
106
+ ```bash
107
+ pytest tests -v
108
+ ```
109
+
110
+ ## Release Tracking
111
+
112
+ - Keep package changes tracked in `CHANGELOG.md`.
113
+ - For every release:
114
+ 1. Update `pyproject.toml` version.
115
+ 2. Add a new dated section in `CHANGELOG.md`.
116
+ 3. Publish and tag the release.
@@ -0,0 +1,96 @@
1
+ # fluxos-agent
2
+
3
+ Python port of `pi-mono/packages/agent` [https://github.com/badlogic/pi-mono/tree/main/packages/agent].
4
+
5
+ `fluxos-agent` provides:
6
+ - Stateful agent runtime (`Agent`) with prompt/continue flows
7
+ - Agent loop primitives (`agent_loop`, `agent_loop_continue`)
8
+ - Tool execution with streaming update events
9
+ - Steering and follow-up queues
10
+ - Optional proxy stream adapter (`stream_proxy`)
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install -e packages/fluxos-agent
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ import asyncio
22
+
23
+ from flux_agent import Agent, AgentOptions
24
+ from flux_agent.types import (
25
+ AssistantDoneEvent,
26
+ AssistantMessage,
27
+ Context,
28
+ Model,
29
+ TextContent,
30
+ Usage,
31
+ UsageCost,
32
+ )
33
+ from flux_agent.event_stream import EventStream
34
+
35
+
36
+ class MockAssistantStream(EventStream):
37
+ def __init__(self):
38
+ super().__init__(
39
+ lambda event: event.type in {"done", "error"},
40
+ lambda event: event.message if event.type == "done" else event.error,
41
+ )
42
+
43
+
44
+ def stream_fn(model: Model, context: Context, options):
45
+ stream = MockAssistantStream()
46
+ message = AssistantMessage(
47
+ content=[TextContent(text="Hello from Python agent")],
48
+ api=model.api,
49
+ provider=model.provider,
50
+ model=model.id,
51
+ usage=Usage(cost=UsageCost()),
52
+ stop_reason="stop",
53
+ )
54
+ stream.push(AssistantDoneEvent(reason="stop", message=message))
55
+ return stream
56
+
57
+
58
+ async def main():
59
+ agent = Agent(
60
+ AgentOptions(
61
+ stream_fn=stream_fn,
62
+ initial_state={
63
+ "system_prompt": "You are helpful.",
64
+ "model": Model(id="mock", name="mock", api="mock", provider="mock"),
65
+ },
66
+ )
67
+ )
68
+
69
+ agent.subscribe(lambda event: print(event.type))
70
+ await agent.prompt("Hello")
71
+
72
+
73
+ asyncio.run(main())
74
+ ```
75
+
76
+ ## Testing
77
+
78
+ ```bash
79
+ cd packages/fluxos-agent
80
+ pip install -e ".[dev]"
81
+ pytest tests/unit -v
82
+ ```
83
+
84
+ Run full suite (unit + e2e scaffolding):
85
+
86
+ ```bash
87
+ pytest tests -v
88
+ ```
89
+
90
+ ## Release Tracking
91
+
92
+ - Keep package changes tracked in `CHANGELOG.md`.
93
+ - For every release:
94
+ 1. Update `pyproject.toml` version.
95
+ 2. Add a new dated section in `CHANGELOG.md`.
96
+ 3. Publish and tag the release.
@@ -0,0 +1,57 @@
1
+ """fluxos-agent - Stateful agent loop and runtime for Python."""
2
+
3
+ from .agent import Agent, AgentOptions
4
+ from .agent_loop import agent_loop, agent_loop_continue
5
+ from .event_stream import EventStream
6
+ from .proxy import ProxyMessageEventStream, ProxyStreamOptions, stream_proxy
7
+ from .types import (
8
+ AgentContext,
9
+ AgentLoopConfig,
10
+ AgentMessage,
11
+ AgentState,
12
+ AgentTool,
13
+ AgentToolResult,
14
+ AssistantMessage,
15
+ AssistantMessageEvent,
16
+ Context,
17
+ ImageContent,
18
+ Message,
19
+ Model,
20
+ TextContent,
21
+ ThinkingBudgets,
22
+ ThinkingContent,
23
+ ThinkingLevel,
24
+ ToolCallContent,
25
+ ToolResultMessage,
26
+ )
27
+ from .version import __version__
28
+
29
+ __all__ = [
30
+ "Agent",
31
+ "AgentOptions",
32
+ "EventStream",
33
+ "agent_loop",
34
+ "agent_loop_continue",
35
+ "stream_proxy",
36
+ "ProxyMessageEventStream",
37
+ "ProxyStreamOptions",
38
+ "Model",
39
+ "Context",
40
+ "Message",
41
+ "AgentMessage",
42
+ "AssistantMessage",
43
+ "ToolResultMessage",
44
+ "AgentContext",
45
+ "AgentLoopConfig",
46
+ "AgentState",
47
+ "AgentTool",
48
+ "AgentToolResult",
49
+ "TextContent",
50
+ "ImageContent",
51
+ "ThinkingContent",
52
+ "ToolCallContent",
53
+ "AssistantMessageEvent",
54
+ "ThinkingLevel",
55
+ "ThinkingBudgets",
56
+ "__version__",
57
+ ]
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable
6
+
7
+ from .agent_loop import agent_loop, agent_loop_continue, build_error_message
8
+ from .types import (
9
+ AgentContext,
10
+ AgentEndEvent,
11
+ AgentEvent,
12
+ AgentLoopConfig,
13
+ AgentMessage,
14
+ AgentState,
15
+ AgentTool,
16
+ ImageContent,
17
+ Message,
18
+ Model,
19
+ TextContent,
20
+ ThinkingBudgets,
21
+ ThinkingLevel,
22
+ Transport,
23
+ message_role,
24
+ )
25
+
26
+
27
+ class _AbortSignal:
28
+ def __init__(self) -> None:
29
+ self._event = asyncio.Event()
30
+
31
+ @property
32
+ def aborted(self) -> bool:
33
+ return self._event.is_set()
34
+
35
+
36
+ class AbortController:
37
+ def __init__(self) -> None:
38
+ self.signal = _AbortSignal()
39
+
40
+ def abort(self) -> None:
41
+ self.signal._event.set()
42
+
43
+
44
+ def default_convert_to_llm(messages: list[AgentMessage]) -> list[Message]:
45
+ return [
46
+ message
47
+ for message in messages
48
+ if message_role(message) in {"user", "assistant", "toolResult"}
49
+ ]
50
+
51
+
52
+ def default_stream_fn(_model: Model, _context: Any, _options: Any):
53
+ raise RuntimeError("No stream function configured. Provide Agent(stream_fn=...) with a provider implementation.")
54
+
55
+
56
+ @dataclass
57
+ class AgentOptions:
58
+ initial_state: AgentState | dict[str, Any] | None = None
59
+ convert_to_llm: Callable[[list[AgentMessage]], list[Message] | Any] | None = None
60
+ transform_context: Callable[[list[AgentMessage], Any | None], Any] | None = None
61
+ steering_mode: str = "one-at-a-time"
62
+ follow_up_mode: str = "one-at-a-time"
63
+ stream_fn: Callable[..., Any] | None = None
64
+ session_id: str | None = None
65
+ get_api_key: Callable[[str], Any] | None = None
66
+ thinking_budgets: ThinkingBudgets | None = None
67
+ transport: Transport = "sse"
68
+ max_retry_delay_ms: int | None = None
69
+
70
+
71
+ class Agent:
72
+ def __init__(self, opts: AgentOptions | None = None, **kwargs: Any) -> None:
73
+ options = opts or AgentOptions(**kwargs)
74
+
75
+ self._state = AgentState()
76
+ if isinstance(options.initial_state, AgentState):
77
+ self._state = options.initial_state
78
+ elif isinstance(options.initial_state, dict):
79
+ for key, value in options.initial_state.items():
80
+ if hasattr(self._state, key):
81
+ setattr(self._state, key, value)
82
+
83
+ self._listeners: set[Callable[[AgentEvent], None]] = set()
84
+ self._abort_controller: AbortController | None = None
85
+ self._convert_to_llm = options.convert_to_llm or default_convert_to_llm
86
+ self._transform_context = options.transform_context
87
+ self._steering_queue: list[AgentMessage] = []
88
+ self._follow_up_queue: list[AgentMessage] = []
89
+ self._steering_mode = options.steering_mode
90
+ self._follow_up_mode = options.follow_up_mode
91
+ self.stream_fn = options.stream_fn or default_stream_fn
92
+ self._session_id = options.session_id
93
+ self.get_api_key = options.get_api_key
94
+ self._thinking_budgets = options.thinking_budgets
95
+ self._transport: Transport = options.transport
96
+ self._max_retry_delay_ms = options.max_retry_delay_ms
97
+ self._running_prompt: asyncio.Task[None] | None = None
98
+
99
+ @property
100
+ def state(self) -> AgentState:
101
+ return self._state
102
+
103
+ @property
104
+ def session_id(self) -> str | None:
105
+ return self._session_id
106
+
107
+ @session_id.setter
108
+ def session_id(self, value: str | None) -> None:
109
+ self._session_id = value
110
+
111
+ @property
112
+ def thinking_budgets(self) -> ThinkingBudgets | None:
113
+ return self._thinking_budgets
114
+
115
+ @thinking_budgets.setter
116
+ def thinking_budgets(self, value: ThinkingBudgets | None) -> None:
117
+ self._thinking_budgets = value
118
+
119
+ @property
120
+ def transport(self) -> Transport:
121
+ return self._transport
122
+
123
+ @property
124
+ def max_retry_delay_ms(self) -> int | None:
125
+ return self._max_retry_delay_ms
126
+
127
+ @max_retry_delay_ms.setter
128
+ def max_retry_delay_ms(self, value: int | None) -> None:
129
+ self._max_retry_delay_ms = value
130
+
131
+ def subscribe(self, fn: Callable[[AgentEvent], None]) -> Callable[[], None]:
132
+ self._listeners.add(fn)
133
+
134
+ def _unsubscribe() -> None:
135
+ self._listeners.discard(fn)
136
+
137
+ return _unsubscribe
138
+
139
+ def set_system_prompt(self, value: str) -> None:
140
+ self._state.system_prompt = value
141
+
142
+ def set_model(self, model: Model) -> None:
143
+ self._state.model = model
144
+
145
+ def set_thinking_level(self, level: ThinkingLevel) -> None:
146
+ self._state.thinking_level = level
147
+
148
+ def set_steering_mode(self, mode: str) -> None:
149
+ self._steering_mode = mode
150
+
151
+ def get_steering_mode(self) -> str:
152
+ return self._steering_mode
153
+
154
+ def set_follow_up_mode(self, mode: str) -> None:
155
+ self._follow_up_mode = mode
156
+
157
+ def get_follow_up_mode(self) -> str:
158
+ return self._follow_up_mode
159
+
160
+ def set_tools(self, tools: list[AgentTool]) -> None:
161
+ self._state.tools = tools
162
+
163
+ def set_transport(self, value: Transport) -> None:
164
+ self._transport = value
165
+
166
+ def replace_messages(self, messages: list[AgentMessage]) -> None:
167
+ self._state.messages = list(messages)
168
+
169
+ def append_message(self, message: AgentMessage) -> None:
170
+ self._state.messages = [*self._state.messages, message]
171
+
172
+ def steer(self, message: AgentMessage) -> None:
173
+ self._steering_queue.append(message)
174
+
175
+ def follow_up(self, message: AgentMessage) -> None:
176
+ self._follow_up_queue.append(message)
177
+
178
+ def clear_steering_queue(self) -> None:
179
+ self._steering_queue = []
180
+
181
+ def clear_follow_up_queue(self) -> None:
182
+ self._follow_up_queue = []
183
+
184
+ def clear_all_queues(self) -> None:
185
+ self._steering_queue = []
186
+ self._follow_up_queue = []
187
+
188
+ def has_queued_messages(self) -> bool:
189
+ return bool(self._steering_queue or self._follow_up_queue)
190
+
191
+ def clear_messages(self) -> None:
192
+ self._state.messages = []
193
+
194
+ def abort(self) -> None:
195
+ if self._abort_controller:
196
+ self._abort_controller.abort()
197
+
198
+ async def wait_for_idle(self) -> None:
199
+ if self._running_prompt:
200
+ await self._running_prompt
201
+
202
+ def reset(self) -> None:
203
+ self._state.messages = []
204
+ self._state.is_streaming = False
205
+ self._state.stream_message = None
206
+ self._state.pending_tool_calls = set()
207
+ self._state.error = None
208
+ self.clear_all_queues()
209
+
210
+ async def prompt(self, input_message: str | AgentMessage | list[AgentMessage], images: list[ImageContent] | None = None) -> None:
211
+ if self._state.is_streaming:
212
+ raise RuntimeError(
213
+ "Agent is already processing a prompt. Use steer() or follow_up() to queue messages, or wait for completion."
214
+ )
215
+
216
+ if isinstance(input_message, list):
217
+ messages = input_message
218
+ elif isinstance(input_message, str):
219
+ content: list[TextContent | ImageContent] = [TextContent(text=input_message)]
220
+ if images:
221
+ content.extend(images)
222
+ messages = [{"role": "user", "content": content, "timestamp": _now_ms()}]
223
+ else:
224
+ messages = [input_message]
225
+
226
+ await self._run_loop(messages)
227
+
228
+ async def continue_(self) -> None:
229
+ if self._state.is_streaming:
230
+ raise RuntimeError("Agent is already processing. Wait for completion before continuing.")
231
+
232
+ messages = self._state.messages
233
+ if not messages:
234
+ raise RuntimeError("No messages to continue from")
235
+
236
+ if message_role(messages[-1]) == "assistant":
237
+ queued_steering = self._dequeue_steering_messages()
238
+ if queued_steering:
239
+ await self._run_loop(queued_steering, skip_initial_steering_poll=True)
240
+ return
241
+
242
+ queued_follow_up = self._dequeue_follow_up_messages()
243
+ if queued_follow_up:
244
+ await self._run_loop(queued_follow_up)
245
+ return
246
+
247
+ raise RuntimeError("Cannot continue from message role: assistant")
248
+
249
+ await self._run_loop(None)
250
+
251
+ async def _run_loop(self, messages: list[AgentMessage] | None, skip_initial_steering_poll: bool = False) -> None:
252
+ model = self._state.model
253
+
254
+ async def _runner() -> None:
255
+ self._abort_controller = AbortController()
256
+ self._state.is_streaming = True
257
+ self._state.stream_message = None
258
+ self._state.error = None
259
+
260
+ reasoning = None if self._state.thinking_level == "off" else self._state.thinking_level
261
+ context = AgentContext(
262
+ system_prompt=self._state.system_prompt,
263
+ messages=list(self._state.messages),
264
+ tools=self._state.tools,
265
+ )
266
+
267
+ local_skip = skip_initial_steering_poll
268
+
269
+ async def _get_steering_messages() -> list[AgentMessage]:
270
+ nonlocal local_skip
271
+ if local_skip:
272
+ local_skip = False
273
+ return []
274
+ return self._dequeue_steering_messages()
275
+
276
+ config = AgentLoopConfig(
277
+ model=model,
278
+ reasoning=reasoning,
279
+ session_id=self._session_id,
280
+ transport=self._transport,
281
+ thinking_budgets=self._thinking_budgets,
282
+ max_retry_delay_ms=self._max_retry_delay_ms,
283
+ convert_to_llm=self._convert_to_llm,
284
+ transform_context=self._transform_context,
285
+ get_api_key=self.get_api_key,
286
+ get_steering_messages=_get_steering_messages,
287
+ get_follow_up_messages=self._dequeue_follow_up_messages,
288
+ )
289
+
290
+ partial: AgentMessage | None = None
291
+
292
+ try:
293
+ stream = (
294
+ agent_loop(messages, context, config, self._abort_controller.signal, self.stream_fn)
295
+ if messages is not None
296
+ else agent_loop_continue(context, config, self._abort_controller.signal, self.stream_fn)
297
+ )
298
+
299
+ async for event in stream:
300
+ event_type = getattr(event, "type", "")
301
+
302
+ if event_type in {"message_start", "message_update"}:
303
+ partial = event.message
304
+ self._state.stream_message = event.message
305
+ elif event_type == "message_end":
306
+ partial = None
307
+ self._state.stream_message = None
308
+ self.append_message(event.message)
309
+ elif event_type == "tool_execution_start":
310
+ pending = set(self._state.pending_tool_calls)
311
+ pending.add(event.tool_call_id)
312
+ self._state.pending_tool_calls = pending
313
+ elif event_type == "tool_execution_end":
314
+ pending = set(self._state.pending_tool_calls)
315
+ pending.discard(event.tool_call_id)
316
+ self._state.pending_tool_calls = pending
317
+ elif event_type == "turn_end":
318
+ if message_role(event.message) == "assistant" and getattr(event.message, "error_message", None):
319
+ self._state.error = str(getattr(event.message, "error_message"))
320
+ elif event_type == "agent_end":
321
+ self._state.is_streaming = False
322
+ self._state.stream_message = None
323
+
324
+ self._emit(event)
325
+
326
+ if partial and message_role(partial) == "assistant":
327
+ content = getattr(partial, "content", [])
328
+ only_empty = not any(_has_non_empty_content(part) for part in content)
329
+ if not only_empty:
330
+ self.append_message(partial)
331
+ elif self._abort_controller and self._abort_controller.signal.aborted:
332
+ raise RuntimeError("Request was aborted")
333
+
334
+ except Exception as exc:
335
+ error_message = build_error_message(
336
+ model,
337
+ exc,
338
+ aborted=bool(self._abort_controller and self._abort_controller.signal.aborted),
339
+ )
340
+ self.append_message(error_message)
341
+ self._state.error = str(exc)
342
+ self._emit(AgentEndEvent(messages=[error_message]))
343
+ finally:
344
+ self._state.is_streaming = False
345
+ self._state.stream_message = None
346
+ self._state.pending_tool_calls = set()
347
+ self._abort_controller = None
348
+
349
+ task = asyncio.create_task(_runner())
350
+ self._running_prompt = task
351
+ try:
352
+ await task
353
+ finally:
354
+ self._running_prompt = None
355
+
356
+ def _dequeue_steering_messages(self) -> list[AgentMessage]:
357
+ if self._steering_mode == "one-at-a-time":
358
+ if self._steering_queue:
359
+ first = self._steering_queue[0]
360
+ self._steering_queue = self._steering_queue[1:]
361
+ return [first]
362
+ return []
363
+
364
+ queued = list(self._steering_queue)
365
+ self._steering_queue = []
366
+ return queued
367
+
368
+ def _dequeue_follow_up_messages(self) -> list[AgentMessage]:
369
+ if self._follow_up_mode == "one-at-a-time":
370
+ if self._follow_up_queue:
371
+ first = self._follow_up_queue[0]
372
+ self._follow_up_queue = self._follow_up_queue[1:]
373
+ return [first]
374
+ return []
375
+
376
+ queued = list(self._follow_up_queue)
377
+ self._follow_up_queue = []
378
+ return queued
379
+
380
+ def _emit(self, event: AgentEvent) -> None:
381
+ for listener in list(self._listeners):
382
+ listener(event)
383
+
384
+ # TypeScript API aliases for easier migration.
385
+ setSystemPrompt = set_system_prompt
386
+ setModel = set_model
387
+ setThinkingLevel = set_thinking_level
388
+ setSteeringMode = set_steering_mode
389
+ getSteeringMode = get_steering_mode
390
+ setFollowUpMode = set_follow_up_mode
391
+ getFollowUpMode = get_follow_up_mode
392
+ setTools = set_tools
393
+ setTransport = set_transport
394
+ replaceMessages = replace_messages
395
+ appendMessage = append_message
396
+ followUp = follow_up
397
+ clearSteeringQueue = clear_steering_queue
398
+ clearFollowUpQueue = clear_follow_up_queue
399
+ clearAllQueues = clear_all_queues
400
+ hasQueuedMessages = has_queued_messages
401
+ clearMessages = clear_messages
402
+ waitForIdle = wait_for_idle
403
+
404
+
405
+ continue_conversation = Agent.continue_
406
+
407
+
408
+ def _has_non_empty_content(part: Any) -> bool:
409
+ if isinstance(part, dict):
410
+ part_type = part.get("type")
411
+ if part_type == "thinking":
412
+ return bool(str(part.get("thinking", "")).strip())
413
+ if part_type == "text":
414
+ return bool(str(part.get("text", "")).strip())
415
+ if part_type == "toolCall":
416
+ return bool(str(part.get("name", "")).strip())
417
+ return False
418
+
419
+ part_type = getattr(part, "type", None)
420
+ if part_type == "thinking":
421
+ return bool(str(getattr(part, "thinking", "")).strip())
422
+ if part_type == "text":
423
+ return bool(str(getattr(part, "text", "")).strip())
424
+ if part_type == "toolCall":
425
+ return bool(str(getattr(part, "name", "")).strip())
426
+ return False
427
+
428
+
429
+ def _now_ms() -> int:
430
+ import time
431
+
432
+ return int(time.time() * 1000)
433
+