ttasks 0.2.0__py3-none-any.whl

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.
ttasks/_executor.py ADDED
@@ -0,0 +1,645 @@
1
+ """Task execution and process-management helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import time
10
+ import warnings
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from types import MappingProxyType
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from ._events import EventBus, TaskEvent, TaskEventType
18
+ from ._exceptions import TaskCancelled, TaskExecutionError, TaskTimeoutError
19
+ from ._task import Task, TaskResult, TaskStatus, TaskType, TerminationReason
20
+
21
+ if TYPE_CHECKING:
22
+ from ._store import Store
23
+
24
+
25
+ @dataclass(frozen=True, init=False)
26
+ class TaskContext:
27
+ """Read-only execution view passed to task handlers.
28
+
29
+ The executor owns lifecycle transitions. Handlers receive this context so
30
+ they can inspect task data, cancellation state, and direct upstream task
31
+ refs without being given the public Task state-machine mutation API for the
32
+ current task.
33
+ """
34
+
35
+ _task: Task
36
+ _upstream: Mapping[str, Task]
37
+
38
+ def __init__(
39
+ self,
40
+ task: Task,
41
+ upstream: Mapping[str, Task] | None = None,
42
+ ) -> None:
43
+ """Create a context for task with read-only upstream task refs."""
44
+ object.__setattr__(self, "_task", task)
45
+ object.__setattr__(self, "_upstream", MappingProxyType(dict(upstream or {})))
46
+
47
+ @property
48
+ def id(self) -> str:
49
+ """Return the task identity."""
50
+ return self._task.id
51
+
52
+ @property
53
+ def title(self) -> str:
54
+ """Return the task title."""
55
+ return self._task.title
56
+
57
+ @property
58
+ def description(self) -> str:
59
+ """Return the task description."""
60
+ return self._task.description
61
+
62
+ @property
63
+ def payload(self) -> str:
64
+ """Return the task payload."""
65
+ return self._task.payload
66
+
67
+ @property
68
+ def type(self) -> TaskType:
69
+ """Return the task type."""
70
+ return self._task.type
71
+
72
+ @property
73
+ def timeout(self) -> float | None:
74
+ """Return the task timeout."""
75
+ return self._task.timeout
76
+
77
+ @property
78
+ def status(self) -> TaskStatus:
79
+ """Return the task's current live status."""
80
+ return self._task.status
81
+
82
+ @property
83
+ def cancelled(self) -> bool:
84
+ """Return whether cancellation has been requested for the task."""
85
+ return self.status == TaskStatus.CANCELLED
86
+
87
+ @property
88
+ def upstream(self) -> Mapping[str, Task]:
89
+ """Return direct upstream task refs keyed by task ID."""
90
+ return self._upstream
91
+
92
+ def raise_if_cancelled(self) -> None:
93
+ """Raise TaskCancelled if cancellation has been requested."""
94
+ if self.cancelled:
95
+ raise TaskCancelled(f"Task {self.id!r} was cancelled")
96
+
97
+
98
+ # Handler contract: returning any value means success and the value is
99
+ # normalized into TaskResult. Raising TaskCancelled means cancelled. Raising any
100
+ # other exception means failed. Handlers that run subprocesses should raise
101
+ # TaskExecutionError or TaskTimeoutError to preserve structured process output.
102
+ TaskHandler = Callable[[TaskContext], Any]
103
+
104
+
105
+ class TaskExecutor:
106
+ """Dispatch tasks to registered handlers and manage task state transitions.
107
+
108
+ When constructed with ``store``, the executor auto-persists each task to
109
+ ``store.tasks`` on every lifecycle transition (RUNNING, SUCCEEDED, FAILED,
110
+ CANCELLED). Persistence runs *before* the corresponding lifecycle event is
111
+ emitted so subscribers can read a consistent store. Persistence failures
112
+ do not propagate as task failures; instead they are recorded on
113
+ :attr:`persistence_errors` and emitted as
114
+ :attr:`TaskEventType.PERSISTENCE_FAILED` events.
115
+ """
116
+
117
+ def __init__(self, store: Store | None = None, *, _register_defaults: bool = True):
118
+ """Create an executor optionally backed by ``store`` for auto-persist.
119
+
120
+ Built-in BASH, POWERSHELL, PROMPT, and AGENT handlers are registered
121
+ automatically. Use :meth:`empty` to construct an executor without them.
122
+ """
123
+ self._handlers: dict[TaskType, TaskHandler] = {}
124
+ self._running_processes: dict[str, subprocess.Popen[str]] = {}
125
+ self.events = EventBus()
126
+ self.store = store
127
+ self.persistence_errors: list[tuple[str, BaseException]] = []
128
+ self.graph_persistence_errors: list[tuple[str, BaseException]] = []
129
+ if _register_defaults:
130
+ self.register(TaskType.BASH, self._run_bash)
131
+ self.register(TaskType.POWERSHELL, self._run_powershell)
132
+ self.register(TaskType.PROMPT, make_copilot_prompt_handler())
133
+ self.register(TaskType.AGENT, make_copilot_agent_handler())
134
+
135
+ @classmethod
136
+ def empty(cls, store: Store | None = None) -> TaskExecutor:
137
+ """Construct an executor with no handlers pre-registered."""
138
+ return cls(store=store, _register_defaults=False)
139
+
140
+ def register(self, task_type: TaskType, handler: TaskHandler) -> None:
141
+ """Register callable handler as the executor for task_type."""
142
+ if not isinstance(task_type, TaskType):
143
+ raise TypeError("task_type must be a TaskType")
144
+ if not callable(handler):
145
+ raise TypeError("handler must be callable")
146
+ self._handlers[task_type] = handler
147
+
148
+ def is_registered(self, task_type: TaskType) -> bool:
149
+ """Return whether a handler is registered for ``task_type``."""
150
+ if not isinstance(task_type, TaskType):
151
+ raise TypeError("task_type must be a TaskType")
152
+ return task_type in self._handlers
153
+
154
+ def is_running(self, task_id: str) -> bool:
155
+ """Return whether task_id currently has a live subprocess."""
156
+ process = self._running_processes.get(task_id)
157
+ return process is not None and process.poll() is None
158
+
159
+ def mark_blocked(self, task: Task, parent_id: str | None) -> None:
160
+ """Transition ``task`` to BLOCKED, recording the parent that caused it.
161
+
162
+ Public seam used by :class:`TaskGraph` (and any custom scheduler) to
163
+ signal that ``task`` cannot proceed because an upstream dependency
164
+ failed the readiness contract (it failed, was cancelled, or is itself
165
+ blocked). Records ``parent_id`` on the task via
166
+ :meth:`Task._set_blocked_by`, drives the lifecycle transition, and
167
+ emits the BLOCKED event so observers and the store see the outcome.
168
+ """
169
+ previous_status = task.status
170
+ task._set_blocked_by(parent_id)
171
+ task.transition_to(TaskStatus.BLOCKED)
172
+ self._emit(task, TaskEventType.BLOCKED, previous_status)
173
+
174
+ def _terminalize(
175
+ self,
176
+ task: Task,
177
+ result: TaskResult,
178
+ status: TaskStatus,
179
+ *,
180
+ previous: TaskStatus,
181
+ event_type: TaskEventType,
182
+ error: str | None = None,
183
+ ) -> None:
184
+ """Drive a single terminal write: result → transition → emit.
185
+
186
+ ``result`` is always attached so race orderings still leave a
187
+ TaskResult in place. If ``task`` is already in ``status`` (e.g. an
188
+ external ``cancel()`` raced ahead mid-execute) the transition is
189
+ skipped because the state-machine rejects self-transitions on
190
+ terminal states, but the event still fires so the executor remains
191
+ the single source of terminal events for its own ``execute()`` call.
192
+ """
193
+ task._set_result(result)
194
+ if task.status != status:
195
+ task.transition_to(status, error=error)
196
+ self._emit(task, event_type, previous, error)
197
+
198
+ def _emit(
199
+ self,
200
+ task: Task,
201
+ event_type: TaskEventType,
202
+ previous_status: TaskStatus | None,
203
+ error: str | None = None,
204
+ ) -> None:
205
+ """Persist ``task`` if a store is configured, then emit a lifecycle event."""
206
+ self._persist(task)
207
+ self.events.emit(
208
+ TaskEvent(
209
+ type=event_type,
210
+ task_id=task.id,
211
+ task=task,
212
+ timestamp=datetime.now(),
213
+ previous_status=previous_status,
214
+ status=task.status,
215
+ error=error,
216
+ )
217
+ )
218
+
219
+ def _persist(self, task: Task) -> None:
220
+ """Auto-save ``task`` to the configured store.
221
+
222
+ Failures are recorded on :attr:`persistence_errors` and emitted as
223
+ ``PERSISTENCE_FAILED`` events; they never propagate to the caller.
224
+ """
225
+ if self.store is None:
226
+ return
227
+ try:
228
+ self.store.tasks.save(task)
229
+ except BaseException as error:
230
+ self.persistence_errors.append((task.id, error))
231
+ self.events.emit(
232
+ TaskEvent(
233
+ type=TaskEventType.PERSISTENCE_FAILED,
234
+ task_id=task.id,
235
+ task=task,
236
+ timestamp=datetime.now(),
237
+ previous_status=None,
238
+ status=task.status,
239
+ error=str(error),
240
+ )
241
+ )
242
+
243
+ def _persist_graph(self, graph: Any) -> None:
244
+ """Auto-save ``graph`` to the configured store.
245
+
246
+ Failures are recorded on :attr:`graph_persistence_errors` and surfaced
247
+ via :func:`warnings.warn`; they never propagate to the caller. Graph
248
+ persistence has no event type because :class:`TaskEvent` is
249
+ task-centric; the list is the discovery channel.
250
+ """
251
+ if self.store is None:
252
+ return
253
+ try:
254
+ self.store.graphs.save(graph)
255
+ except BaseException as error:
256
+ self.graph_persistence_errors.append((graph.id, error))
257
+ warnings.warn(
258
+ f"graph persistence failed for graph {graph.id!r}: {error}",
259
+ stacklevel=2,
260
+ )
261
+
262
+ def cancel(self, task: Task) -> None:
263
+ """Cancel a task and terminate its subprocess if one is active.
264
+
265
+ SUCCEEDED is an irreversible sink: cancel() is a silent no-op rather
266
+ than raising, so callers don't need to know which states accept
267
+ transitions. For tasks that were not actively executing (PENDING /
268
+ FAILED / BLOCKED) this emits a CANCELLED event and attaches a
269
+ CANCELLED ``TaskResult`` so observers and the store see the outcome.
270
+ Cancelling a RUNNING task does **not** emit here: the active
271
+ ``execute()`` loop owns the terminal event for that task and
272
+ will emit CANCELLED when its handler unwinds via
273
+ :class:`TaskCancelled`. Cancelling an already-CANCELLED task is
274
+ a no-op on Task state but still reaps any lingering subprocess
275
+ so duplicate requests stay harmless yet complete.
276
+ """
277
+ if task.status == TaskStatus.SUCCEEDED:
278
+ return
279
+
280
+ previous = task.status
281
+ task.cancel()
282
+
283
+ process = self._running_processes.get(task.id)
284
+ if process is not None and process.poll() is None:
285
+ self._terminate_process(process)
286
+
287
+ if previous in {TaskStatus.PENDING, TaskStatus.FAILED, TaskStatus.BLOCKED}:
288
+ now = datetime.now()
289
+ result = TaskResult(
290
+ task_id=task.id,
291
+ status=TaskStatus.CANCELLED,
292
+ started_at=now,
293
+ finished_at=now,
294
+ duration=0.0,
295
+ error="cancelled",
296
+ termination_reason="cancelled",
297
+ )
298
+ self._terminalize(
299
+ task,
300
+ result,
301
+ TaskStatus.CANCELLED,
302
+ previous=previous,
303
+ event_type=TaskEventType.CANCELLED,
304
+ error="cancelled",
305
+ )
306
+
307
+ def execute(
308
+ self,
309
+ task: Task,
310
+ upstream: Mapping[str, Task] | None = None,
311
+ ) -> TaskResult:
312
+ """Execute task with its registered handler.
313
+
314
+ upstream contains direct dependency task refs keyed by task ID. Single
315
+ task execution normally leaves it empty; TaskGraph populates it from
316
+ the graph ledger before submitting each non-root task.
317
+
318
+ Execution always moves through RUNNING first. Returning from a handler
319
+ means success and moves the task to SUCCEEDED; raising from a handler means
320
+ failure unless cancellation happened while the handler was in flight.
321
+ Handlers should signal cooperative cancellation by raising TaskCancelled
322
+ rather than mutating task state directly; the executor performs the
323
+ CANCELLED transition.
324
+
325
+ A non-zero subprocess return code is not interpreted here for arbitrary
326
+ custom handlers. Handlers that want subprocess failures represented as
327
+ structured TaskResult data should raise TaskExecutionError or
328
+ TaskTimeoutError.
329
+ """
330
+ if not task.can_transition_to(TaskStatus.RUNNING):
331
+ raise ValueError(f"Cannot execute task with status {task.status.value!r}")
332
+
333
+ handler = self._handlers.get(task.type)
334
+ if handler is None:
335
+ message = f"No handler registered for task type {task.type.value!r}"
336
+ finished_at = datetime.now()
337
+ failed_result = TaskResult(
338
+ task_id=task.id,
339
+ status=TaskStatus.FAILED,
340
+ started_at=finished_at,
341
+ finished_at=finished_at,
342
+ duration=0.0,
343
+ error=message,
344
+ termination_reason="handler",
345
+ )
346
+ self._terminalize(
347
+ task,
348
+ failed_result,
349
+ TaskStatus.FAILED,
350
+ previous=TaskStatus.PENDING,
351
+ event_type=TaskEventType.FAILED,
352
+ error=message,
353
+ )
354
+ raise ValueError(message)
355
+
356
+ previous_status = task.status
357
+ task.transition_to(TaskStatus.RUNNING)
358
+ self._emit(task, TaskEventType.STARTED, previous_status)
359
+ started_at = datetime.now()
360
+ started_monotonic = time.monotonic()
361
+
362
+ def build_result(status: TaskStatus, **extras: Any) -> TaskResult:
363
+ """Build the terminal TaskResult for ``task``."""
364
+ finished_at = datetime.now()
365
+ return TaskResult(
366
+ task_id=task.id,
367
+ status=status,
368
+ started_at=started_at,
369
+ finished_at=finished_at,
370
+ duration=time.monotonic() - started_monotonic,
371
+ **extras,
372
+ )
373
+
374
+ context = TaskContext(task, upstream=upstream)
375
+
376
+ try:
377
+ raw_result = handler(context)
378
+ context.raise_if_cancelled()
379
+ finished_at = datetime.now()
380
+ duration = time.monotonic() - started_monotonic
381
+ result = TaskResult.from_raw(
382
+ task,
383
+ raw_result,
384
+ status=TaskStatus.SUCCEEDED,
385
+ started_at=started_at,
386
+ finished_at=finished_at,
387
+ duration=duration,
388
+ )
389
+ self._terminalize(
390
+ task,
391
+ result,
392
+ TaskStatus.SUCCEEDED,
393
+ previous=TaskStatus.RUNNING,
394
+ event_type=TaskEventType.SUCCEEDED,
395
+ )
396
+ return result
397
+ except TaskCancelled as e:
398
+ cancelled_result = build_result(
399
+ TaskStatus.CANCELLED, error=str(e), termination_reason="cancelled"
400
+ )
401
+ self._terminalize(
402
+ task,
403
+ cancelled_result,
404
+ TaskStatus.CANCELLED,
405
+ previous=TaskStatus.RUNNING,
406
+ event_type=TaskEventType.CANCELLED,
407
+ error=str(e),
408
+ )
409
+ raise
410
+ except Exception as e:
411
+ if task.status == TaskStatus.CANCELLED:
412
+ cancelled = TaskCancelled(f"Task {task.id!r} was cancelled")
413
+ cancelled_result = build_result(
414
+ TaskStatus.CANCELLED, error=str(e), termination_reason="cancelled"
415
+ )
416
+ self._terminalize(
417
+ task,
418
+ cancelled_result,
419
+ TaskStatus.CANCELLED,
420
+ previous=TaskStatus.RUNNING,
421
+ event_type=TaskEventType.CANCELLED,
422
+ error=str(e),
423
+ )
424
+ raise cancelled from e
425
+ if isinstance(e, TaskExecutionError | TaskTimeoutError):
426
+ completed = e.completed
427
+ if isinstance(e, TaskExecutionError):
428
+ err_text = completed.stderr or str(e)
429
+ reason: TerminationReason = "exit_code"
430
+ else:
431
+ err_text = str(e)
432
+ reason = "timeout"
433
+ failed_result = build_result(
434
+ TaskStatus.FAILED,
435
+ output=completed.stdout or "",
436
+ error=err_text,
437
+ returncode=completed.returncode,
438
+ raw=completed,
439
+ termination_reason=reason,
440
+ )
441
+ else:
442
+ failed_result = build_result(
443
+ TaskStatus.FAILED, error=str(e), termination_reason="handler"
444
+ )
445
+ self._terminalize(
446
+ task,
447
+ failed_result,
448
+ TaskStatus.FAILED,
449
+ previous=TaskStatus.RUNNING,
450
+ event_type=TaskEventType.FAILED,
451
+ error=str(e),
452
+ )
453
+ raise
454
+
455
+ def _run_command(
456
+ self,
457
+ context: TaskContext,
458
+ args: str | list[str],
459
+ *,
460
+ shell: bool = False,
461
+ ) -> subprocess.CompletedProcess[str]:
462
+ """Run a subprocess for task and enforce cancellation/timeout behavior.
463
+
464
+ context.timeout=None follows subprocess semantics: wait indefinitely
465
+ unless another caller cancels the task through TaskExecutor.cancel().
466
+ Non-zero exits raise TaskExecutionError; timeouts raise
467
+ TaskTimeoutError. Both exceptions carry a CompletedProcess so execute()
468
+ can attach stdout, stderr, returncode, and raw process details.
469
+ """
470
+ process = subprocess.Popen(
471
+ args,
472
+ shell=shell,
473
+ text=True,
474
+ stdout=subprocess.PIPE,
475
+ stderr=subprocess.PIPE,
476
+ start_new_session=True,
477
+ )
478
+ self._running_processes[context.id] = process
479
+ if context.cancelled:
480
+ self._terminate_process(process)
481
+ try:
482
+ try:
483
+ stdout, stderr = process.communicate(timeout=context.timeout)
484
+ except subprocess.TimeoutExpired as e:
485
+ self._terminate_process(process)
486
+ stdout, stderr = process.communicate()
487
+ message = f"Task timed out after {context.timeout} seconds"
488
+ completed = subprocess.CompletedProcess(
489
+ args=args,
490
+ returncode=process.returncode,
491
+ stdout=stdout,
492
+ stderr=stderr,
493
+ )
494
+ raise TaskTimeoutError(message, completed) from e
495
+ finally:
496
+ self._running_processes.pop(context.id, None)
497
+
498
+ result = subprocess.CompletedProcess(
499
+ args=args,
500
+ returncode=process.returncode,
501
+ stdout=stdout,
502
+ stderr=stderr,
503
+ )
504
+ if result.returncode != 0:
505
+ if context.cancelled:
506
+ raise TaskCancelled(f"Task {context.id!r} was cancelled")
507
+ message = result.stderr or f"exited with code {result.returncode}"
508
+ raise TaskExecutionError(message, result)
509
+ return result
510
+
511
+ @staticmethod
512
+ def _terminate_process(process: subprocess.Popen[str]) -> None:
513
+ """Terminate a process group, escalating to SIGKILL if needed."""
514
+ try:
515
+ os.killpg(process.pid, signal.SIGTERM)
516
+ except ProcessLookupError:
517
+ return
518
+
519
+ try:
520
+ process.wait(timeout=5)
521
+ except subprocess.TimeoutExpired:
522
+ try:
523
+ os.killpg(process.pid, signal.SIGKILL)
524
+ except ProcessLookupError:
525
+ return
526
+ process.wait()
527
+
528
+ def _run_bash(self, context: TaskContext) -> subprocess.CompletedProcess[str]:
529
+ """Run trusted bash payload text through the system shell."""
530
+ # Intentionally uses shell=True because TaskType.BASH represents trusted
531
+ # shell code, not a shell-free argv command.
532
+ return self._run_command(context, context.payload, shell=True)
533
+
534
+ def _run_powershell(self, context: TaskContext) -> subprocess.CompletedProcess[str]:
535
+ """Run trusted PowerShell payload text with pwsh."""
536
+ return self._run_command(context, ["pwsh", "-Command", context.payload])
537
+
538
+
539
+ DEFAULT_COPILOT_PROMPT_MODEL = "gpt-5.4-mini"
540
+ DEFAULT_COPILOT_PROMPT_TIMEOUT = 60.0
541
+ DEFAULT_COPILOT_AGENT_MODEL = "gpt-5.5"
542
+
543
+
544
+ def _make_copilot_handler(
545
+ *,
546
+ model: str,
547
+ default_timeout: float | None,
548
+ tools_enabled: bool,
549
+ ) -> TaskHandler:
550
+ """Return a handler that drives one Copilot turn per task execution."""
551
+ if not model:
552
+ raise ValueError("model must not be empty")
553
+
554
+ def handler(context: TaskContext) -> str:
555
+ """Run one synchronous Copilot task through the async SDK."""
556
+ return asyncio.run(
557
+ _run_copilot_text(
558
+ context,
559
+ model=model,
560
+ default_timeout=default_timeout,
561
+ tools_enabled=tools_enabled,
562
+ )
563
+ )
564
+
565
+ return handler
566
+
567
+
568
+ def make_copilot_prompt_handler(
569
+ *,
570
+ model: str = DEFAULT_COPILOT_PROMPT_MODEL,
571
+ timeout: float = DEFAULT_COPILOT_PROMPT_TIMEOUT,
572
+ ) -> TaskHandler:
573
+ """Return a PROMPT handler backed by the GitHub Copilot SDK.
574
+
575
+ The handler sends context.payload as a single-turn text prompt, disables
576
+ tools with an empty available_tools allowlist, and returns the assistant
577
+ message content as task output. context.timeout overrides timeout per task.
578
+ """
579
+ if timeout <= 0:
580
+ raise ValueError("timeout must be greater than 0")
581
+ return _make_copilot_handler(
582
+ model=model, default_timeout=timeout, tools_enabled=False,
583
+ )
584
+
585
+
586
+ def make_copilot_agent_handler(
587
+ *,
588
+ model: str = DEFAULT_COPILOT_AGENT_MODEL,
589
+ ) -> TaskHandler:
590
+ """Return an AGENT handler backed by the GitHub Copilot SDK.
591
+
592
+ The handler sends context.payload as a single-turn agent instruction,
593
+ leaves Copilot's default tools enabled, approves permission requests, and
594
+ returns the assistant message content as task output. context.timeout is
595
+ used when provided; otherwise no ttasks timeout is applied.
596
+ """
597
+ return _make_copilot_handler(
598
+ model=model, default_timeout=None, tools_enabled=True,
599
+ )
600
+
601
+
602
+ async def _run_copilot_text(
603
+ context: TaskContext,
604
+ *,
605
+ model: str,
606
+ default_timeout: float | None,
607
+ tools_enabled: bool,
608
+ ) -> str:
609
+ """Send one Copilot turn and return assistant text."""
610
+ from copilot import CopilotClient
611
+ from copilot.generated.session_events import AssistantMessageData
612
+ from copilot.session import PermissionHandler
613
+
614
+ context.raise_if_cancelled()
615
+ effective_timeout = (
616
+ context.timeout if context.timeout is not None else default_timeout
617
+ )
618
+
619
+ async with CopilotClient() as client:
620
+ context.raise_if_cancelled()
621
+ if tools_enabled:
622
+ session_context = await client.create_session(
623
+ on_permission_request=PermissionHandler.approve_all,
624
+ model=model,
625
+ )
626
+ else:
627
+ session_context = await client.create_session(
628
+ on_permission_request=PermissionHandler.approve_all,
629
+ model=model,
630
+ available_tools=[],
631
+ )
632
+ async with session_context as session:
633
+ context.raise_if_cancelled()
634
+ send_and_wait: Any = session.send_and_wait
635
+ response = await send_and_wait(
636
+ context.payload,
637
+ timeout=effective_timeout,
638
+ )
639
+
640
+ context.raise_if_cancelled()
641
+ if response is None or not isinstance(response.data, AssistantMessageData):
642
+ return ""
643
+ return response.data.content or ""
644
+
645
+