ttasks 0.3.0__tar.gz → 0.4.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.
- {ttasks-0.3.0 → ttasks-0.4.0}/PKG-INFO +1 -1
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/quickstart.md +20 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/__init__.py +2 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_executor.py +93 -15
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_graph.py +8 -1
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_version.py +2 -2
- ttasks-0.4.0/src/ttasks/copilot.py +329 -0
- ttasks-0.4.0/tests/test_copilot_session.py +549 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_e2e.py +52 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_executor.py +256 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_public_api.py +3 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_workflow.py +139 -14
- {ttasks-0.3.0 → ttasks-0.4.0}/.github/workflows/docs.yml +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/.github/workflows/publish.yml +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/.gitignore +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/.python-version +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/.vscode/launch.json +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/.vscode/settings.json +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/README.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/index.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/patterns/finally-tasks.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/patterns/progress-and-output.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/patterns/retries-and-cancellation.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/reference/api.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/tutorials/async-execution.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/tutorials/graph-workflows.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/docs/tutorials/task-execution.md +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/examples/finally_tasks.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/main.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/mkdocs.yml +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/pyproject.toml +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/scripts/preflight.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_events.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_exceptions.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_sqlite.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_store.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_task.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/py.typed +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/conftest.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_events.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_sqlite_store.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_store.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_task.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_task_factories.py +0 -0
- {ttasks-0.3.0 → ttasks-0.4.0}/uv.lock +0 -0
|
@@ -19,6 +19,26 @@ assert task.result is result
|
|
|
19
19
|
and Copilot agent tasks. You can override any handler or create an executor with
|
|
20
20
|
no defaults by using `TaskExecutor.empty()`.
|
|
21
21
|
|
|
22
|
+
## Share a Copilot agent session
|
|
23
|
+
|
|
24
|
+
The default Copilot agent handler creates a fresh Copilot session for each task.
|
|
25
|
+
Use `CopilotAgentSession` when multiple `Task.agent(...)` tasks should share one
|
|
26
|
+
conversation:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from ttasks import CopilotAgentSession, Task, TaskExecutor, TaskType
|
|
30
|
+
|
|
31
|
+
with CopilotAgentSession(working_directory="/path/to/repo") as agent:
|
|
32
|
+
executor = TaskExecutor()
|
|
33
|
+
executor.register(TaskType.AGENT, agent.handler())
|
|
34
|
+
|
|
35
|
+
executor.execute(Task.agent("Create a first change."))
|
|
36
|
+
executor.execute(Task.agent("Continue from the previous change."))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Shared sessions preserve conversation state across agent tasks. The handler
|
|
40
|
+
serializes turns through the session, including when used by `TaskGraph`.
|
|
41
|
+
|
|
22
42
|
## Run a graph
|
|
23
43
|
|
|
24
44
|
```python
|
|
@@ -20,12 +20,14 @@ from ._store import (
|
|
|
20
20
|
Store,
|
|
21
21
|
)
|
|
22
22
|
from ._task import Task, TaskResult, TaskStatus, TaskType
|
|
23
|
+
from .copilot import CopilotAgentSession
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"EventBus",
|
|
26
27
|
"InMemoryStore",
|
|
27
28
|
"SQLiteStore",
|
|
28
29
|
"Store",
|
|
30
|
+
"CopilotAgentSession",
|
|
29
31
|
"RetryPolicy",
|
|
30
32
|
"Task",
|
|
31
33
|
"TaskCancelled",
|
|
@@ -11,9 +11,10 @@ import time
|
|
|
11
11
|
import warnings
|
|
12
12
|
from collections.abc import Callable, Mapping
|
|
13
13
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
14
|
+
from contextlib import suppress
|
|
14
15
|
from dataclasses import dataclass
|
|
15
16
|
from datetime import datetime
|
|
16
|
-
from threading import RLock, Thread
|
|
17
|
+
from threading import RLock, Thread, current_thread
|
|
17
18
|
from types import MappingProxyType
|
|
18
19
|
from typing import TYPE_CHECKING, Any, TextIO, cast
|
|
19
20
|
|
|
@@ -236,6 +237,8 @@ class TaskExecutor:
|
|
|
236
237
|
:meth:`Future.cancel` only cancels work that has not started yet; cancel
|
|
237
238
|
running tasks through :meth:`cancel`.
|
|
238
239
|
"""
|
|
240
|
+
self._validate_task(task)
|
|
241
|
+
policy = self._resolve_retry_policy(retry_policy)
|
|
239
242
|
# Shallow-copy the mapping so caller mutation cannot race the worker;
|
|
240
243
|
# Task refs themselves intentionally remain shared.
|
|
241
244
|
upstream_snapshot = dict(upstream or {})
|
|
@@ -244,12 +247,27 @@ class TaskExecutor:
|
|
|
244
247
|
raise RuntimeError("executor is shut down")
|
|
245
248
|
if self._pool is None:
|
|
246
249
|
self._pool = ThreadPoolExecutor(thread_name_prefix="ttasks")
|
|
247
|
-
|
|
248
|
-
self.
|
|
250
|
+
future = self._pool.submit(
|
|
251
|
+
self._execute_submitted,
|
|
249
252
|
task,
|
|
250
253
|
upstream_snapshot,
|
|
251
|
-
|
|
254
|
+
policy,
|
|
252
255
|
)
|
|
256
|
+
future.add_done_callback(
|
|
257
|
+
lambda submitted: self.cancel(task) if submitted.cancelled() else None
|
|
258
|
+
)
|
|
259
|
+
return future
|
|
260
|
+
|
|
261
|
+
def _execute_submitted(
|
|
262
|
+
self,
|
|
263
|
+
task: Task,
|
|
264
|
+
upstream: Mapping[str, Task],
|
|
265
|
+
retry_policy: RetryPolicy,
|
|
266
|
+
) -> TaskResult:
|
|
267
|
+
"""Execute submitted work, preserving queued cancellation semantics."""
|
|
268
|
+
if task.status == TaskStatus.CANCELLED:
|
|
269
|
+
raise TaskCancelled(f"Task {task.id!r} was cancelled")
|
|
270
|
+
return self.execute(task, upstream, retry_policy=retry_policy)
|
|
253
271
|
|
|
254
272
|
def shutdown(self) -> None:
|
|
255
273
|
"""Shut down async submission, waiting for submitted work to finish.
|
|
@@ -265,7 +283,15 @@ class TaskExecutor:
|
|
|
265
283
|
pool = self._pool
|
|
266
284
|
self._pool = None
|
|
267
285
|
if pool is not None:
|
|
268
|
-
|
|
286
|
+
current = current_thread()
|
|
287
|
+
pool_threads = list(getattr(pool, "_threads", ()))
|
|
288
|
+
if current in pool_threads:
|
|
289
|
+
pool.shutdown(wait=False)
|
|
290
|
+
for thread in pool_threads:
|
|
291
|
+
if thread is not current:
|
|
292
|
+
thread.join()
|
|
293
|
+
else:
|
|
294
|
+
pool.shutdown(wait=True)
|
|
269
295
|
|
|
270
296
|
def close(self) -> None:
|
|
271
297
|
"""Alias for :meth:`shutdown` for resource-cleanup contexts."""
|
|
@@ -417,10 +443,11 @@ class TaskExecutor:
|
|
|
417
443
|
self.store.graphs.save(graph)
|
|
418
444
|
except BaseException as error:
|
|
419
445
|
self.graph_persistence_errors.append((graph.id, error))
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
446
|
+
with suppress(Warning):
|
|
447
|
+
warnings.warn(
|
|
448
|
+
f"graph persistence failed for graph {graph.id!r}: {error}",
|
|
449
|
+
stacklevel=2,
|
|
450
|
+
)
|
|
424
451
|
|
|
425
452
|
def cancel(self, task: Task) -> None:
|
|
426
453
|
"""Cancel a task and terminate its subprocess if one is active.
|
|
@@ -495,7 +522,8 @@ class TaskExecutor:
|
|
|
495
522
|
``retry_policy`` retries failed attempts for this single task only.
|
|
496
523
|
Cancellation is never retried.
|
|
497
524
|
"""
|
|
498
|
-
|
|
525
|
+
self._validate_task(task)
|
|
526
|
+
policy = self._resolve_retry_policy(retry_policy)
|
|
499
527
|
if policy.max_attempts == 1 or self._handlers.get(task.type) is None:
|
|
500
528
|
return self._execute_once(task, upstream)
|
|
501
529
|
|
|
@@ -513,7 +541,7 @@ class TaskExecutor:
|
|
|
513
541
|
if out_of_attempts or task.status != TaskStatus.FAILED:
|
|
514
542
|
raise
|
|
515
543
|
if policy.backoff:
|
|
516
|
-
|
|
544
|
+
self._sleep_retry_backoff(task, policy.backoff)
|
|
517
545
|
if task.status == TaskStatus.CANCELLED:
|
|
518
546
|
raise TaskCancelled(
|
|
519
547
|
f"Task {task.id!r} was cancelled",
|
|
@@ -521,6 +549,35 @@ class TaskExecutor:
|
|
|
521
549
|
|
|
522
550
|
raise AssertionError("unreachable retry loop exit") # pragma: no cover
|
|
523
551
|
|
|
552
|
+
@staticmethod
|
|
553
|
+
def _validate_task(task: Task) -> None:
|
|
554
|
+
"""Reject malformed task arguments before accessing Task internals."""
|
|
555
|
+
if not isinstance(task, Task):
|
|
556
|
+
raise TypeError("task must be a Task")
|
|
557
|
+
|
|
558
|
+
@staticmethod
|
|
559
|
+
def _resolve_retry_policy(retry_policy: RetryPolicy | None) -> RetryPolicy:
|
|
560
|
+
"""Return a concrete RetryPolicy, rejecting malformed public input."""
|
|
561
|
+
if retry_policy is None:
|
|
562
|
+
return RetryPolicy()
|
|
563
|
+
if not isinstance(retry_policy, RetryPolicy):
|
|
564
|
+
raise TypeError("retry_policy must be a RetryPolicy")
|
|
565
|
+
return retry_policy
|
|
566
|
+
|
|
567
|
+
@staticmethod
|
|
568
|
+
def _sleep_retry_backoff(task: Task, backoff: float) -> None:
|
|
569
|
+
"""Sleep between retry attempts while periodically observing cancel()."""
|
|
570
|
+
if backoff <= 0.5:
|
|
571
|
+
time.sleep(backoff)
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
deadline = time.monotonic() + backoff
|
|
575
|
+
while task.status != TaskStatus.CANCELLED:
|
|
576
|
+
remaining = deadline - time.monotonic()
|
|
577
|
+
if remaining <= 0:
|
|
578
|
+
return
|
|
579
|
+
time.sleep(min(remaining, 0.05))
|
|
580
|
+
|
|
524
581
|
def _execute_once(
|
|
525
582
|
self,
|
|
526
583
|
task: Task,
|
|
@@ -714,6 +771,16 @@ class TaskExecutor:
|
|
|
714
771
|
stderr_thread.start()
|
|
715
772
|
timed_out = False
|
|
716
773
|
timeout_error: subprocess.TimeoutExpired | None = None
|
|
774
|
+
deadline = (
|
|
775
|
+
None if context.timeout is None else time.monotonic() + context.timeout
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
def remaining_timeout() -> float | None:
|
|
779
|
+
"""Return remaining wall-clock timeout for process/output draining."""
|
|
780
|
+
if deadline is None:
|
|
781
|
+
return None
|
|
782
|
+
return max(0.0, deadline - time.monotonic())
|
|
783
|
+
|
|
717
784
|
if context.cancelled:
|
|
718
785
|
self._terminate_process(process)
|
|
719
786
|
try:
|
|
@@ -724,14 +791,23 @@ class TaskExecutor:
|
|
|
724
791
|
timed_out = True
|
|
725
792
|
timeout_error = e
|
|
726
793
|
finally:
|
|
727
|
-
stdout_thread
|
|
728
|
-
|
|
794
|
+
for thread in (stdout_thread, stderr_thread):
|
|
795
|
+
thread.join(remaining_timeout())
|
|
796
|
+
if (
|
|
797
|
+
not timed_out
|
|
798
|
+
and deadline is not None
|
|
799
|
+
and (stdout_thread.is_alive() or stderr_thread.is_alive())
|
|
800
|
+
):
|
|
801
|
+
self._terminate_process(process)
|
|
802
|
+
timed_out = True
|
|
803
|
+
if timed_out:
|
|
804
|
+
stdout_thread.join()
|
|
805
|
+
stderr_thread.join()
|
|
729
806
|
self._running_processes.pop(context.id, None)
|
|
730
807
|
|
|
731
808
|
stdout = "".join(stdout_chunks)
|
|
732
809
|
stderr = "".join(stderr_chunks)
|
|
733
810
|
if timed_out:
|
|
734
|
-
assert timeout_error is not None
|
|
735
811
|
message = f"Task timed out after {context.timeout} seconds"
|
|
736
812
|
completed = subprocess.CompletedProcess(
|
|
737
813
|
args=args,
|
|
@@ -739,7 +815,9 @@ class TaskExecutor:
|
|
|
739
815
|
stdout=stdout,
|
|
740
816
|
stderr=stderr,
|
|
741
817
|
)
|
|
742
|
-
|
|
818
|
+
if timeout_error is not None:
|
|
819
|
+
raise TaskTimeoutError(message, completed) from timeout_error
|
|
820
|
+
raise TaskTimeoutError(message, completed)
|
|
743
821
|
|
|
744
822
|
result = subprocess.CompletedProcess(
|
|
745
823
|
args=args,
|
|
@@ -72,6 +72,8 @@ class TaskGraph:
|
|
|
72
72
|
self._tasks[task.id] = task
|
|
73
73
|
deps: list[str] = []
|
|
74
74
|
for dep in after:
|
|
75
|
+
if not isinstance(dep, Task):
|
|
76
|
+
raise TypeError(f"Expected Task dependency, got {type(dep).__name__}")
|
|
75
77
|
if dep.id not in deps:
|
|
76
78
|
deps.append(dep.id)
|
|
77
79
|
self._deps[task.id] = deps
|
|
@@ -349,11 +351,12 @@ class TaskGraph:
|
|
|
349
351
|
|
|
350
352
|
def inactive(tid: str) -> bool:
|
|
351
353
|
"""Return whether tid can no longer change in this run."""
|
|
354
|
+
if tid in futures:
|
|
355
|
+
return futures[tid].done()
|
|
352
356
|
task = self._tasks[tid]
|
|
353
357
|
return (
|
|
354
358
|
(task.is_terminal and not retryable_this_run(tid))
|
|
355
359
|
or tid in self._errors
|
|
356
|
-
or (tid in futures and futures[tid].done())
|
|
357
360
|
)
|
|
358
361
|
|
|
359
362
|
def ready(tid: str) -> bool:
|
|
@@ -373,6 +376,10 @@ class TaskGraph:
|
|
|
373
376
|
authoritative.
|
|
374
377
|
"""
|
|
375
378
|
for d in self._deps[tid]:
|
|
379
|
+
if d in futures and not futures[d].done():
|
|
380
|
+
continue
|
|
381
|
+
if d in self._errors:
|
|
382
|
+
return d
|
|
376
383
|
if self._tasks[d].status.is_bad and not retryable_this_run(d):
|
|
377
384
|
return d
|
|
378
385
|
return None
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Copilot-backed task handlers and session helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import CancelledError as FutureCancelledError
|
|
8
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
9
|
+
from threading import Event, Lock, Thread
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
from ._exceptions import TaskCancelled
|
|
13
|
+
from ._executor import DEFAULT_COPILOT_AGENT_MODEL, TaskContext, TaskHandler
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CopilotAgentSession:
|
|
17
|
+
"""Long-lived Copilot agent session with a ``TaskExecutor`` handler.
|
|
18
|
+
|
|
19
|
+
A shared session preserves Copilot conversation state across multiple
|
|
20
|
+
``Task.agent(...)`` executions. Use a fresh ``CopilotAgentSession`` for an
|
|
21
|
+
independent conversational lane.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
model: str = DEFAULT_COPILOT_AGENT_MODEL,
|
|
28
|
+
reasoning_effort: str | None = None,
|
|
29
|
+
working_directory: str | None = None,
|
|
30
|
+
timeout: float | None = None,
|
|
31
|
+
on_event: Callable[[Any], None] | None = None,
|
|
32
|
+
**session_options: Any,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Configure a reusable Copilot agent session.
|
|
35
|
+
|
|
36
|
+
``session_options`` are passed through to
|
|
37
|
+
``CopilotClient.create_session(...)``. Permission requests default to
|
|
38
|
+
``PermissionHandler.approve_all`` to match the existing one-shot AGENT
|
|
39
|
+
handler unless ``on_permission_request`` is supplied.
|
|
40
|
+
"""
|
|
41
|
+
if not model:
|
|
42
|
+
raise ValueError("model must not be empty")
|
|
43
|
+
if timeout is not None and timeout <= 0:
|
|
44
|
+
raise ValueError("timeout must be greater than 0")
|
|
45
|
+
|
|
46
|
+
self.model = model
|
|
47
|
+
self.reasoning_effort = reasoning_effort
|
|
48
|
+
self.working_directory = working_directory
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self._session_options = dict(session_options)
|
|
51
|
+
|
|
52
|
+
self._event_handlers: list[Callable[[Any], None]] = []
|
|
53
|
+
self.event_errors: list[BaseException] = []
|
|
54
|
+
self._event_lock = Lock()
|
|
55
|
+
if on_event is not None:
|
|
56
|
+
self._event_handlers.append(on_event)
|
|
57
|
+
|
|
58
|
+
self._client: Any | None = None
|
|
59
|
+
self._session: Any | None = None
|
|
60
|
+
self._send_lock: asyncio.Lock | None = None
|
|
61
|
+
self._active = False
|
|
62
|
+
self._sync_active = False
|
|
63
|
+
|
|
64
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
65
|
+
self._thread: Thread | None = None
|
|
66
|
+
self._thread_ready = Event()
|
|
67
|
+
self._handler_lock = Lock()
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> Self:
|
|
70
|
+
"""Open the Copilot client and session in the current event loop."""
|
|
71
|
+
if self._active:
|
|
72
|
+
raise RuntimeError("CopilotAgentSession is already active")
|
|
73
|
+
await self._open_async()
|
|
74
|
+
self._sync_active = False
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(
|
|
78
|
+
self,
|
|
79
|
+
exc_type: type[BaseException] | None,
|
|
80
|
+
exc: BaseException | None,
|
|
81
|
+
traceback: object | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Close the Copilot session and client."""
|
|
84
|
+
await self._close_async(exc_type, exc, traceback)
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> Self:
|
|
87
|
+
"""Open the Copilot client and session on a background event loop."""
|
|
88
|
+
if self._active:
|
|
89
|
+
raise RuntimeError("CopilotAgentSession is already active")
|
|
90
|
+
self._start_loop_thread()
|
|
91
|
+
try:
|
|
92
|
+
self._run_on_loop(self._open_async())
|
|
93
|
+
except BaseException:
|
|
94
|
+
self._stop_loop_thread()
|
|
95
|
+
raise
|
|
96
|
+
self._sync_active = True
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(
|
|
100
|
+
self,
|
|
101
|
+
exc_type: type[BaseException] | None,
|
|
102
|
+
exc: BaseException | None,
|
|
103
|
+
traceback: object | None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Close the Copilot session/client and stop the background loop."""
|
|
106
|
+
try:
|
|
107
|
+
if self._loop is not None:
|
|
108
|
+
self._run_on_loop(self._close_async(exc_type, exc, traceback))
|
|
109
|
+
finally:
|
|
110
|
+
self._sync_active = False
|
|
111
|
+
self._stop_loop_thread()
|
|
112
|
+
|
|
113
|
+
async def send_and_wait(
|
|
114
|
+
self,
|
|
115
|
+
prompt: str,
|
|
116
|
+
*,
|
|
117
|
+
timeout: float | None = None,
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Send ``prompt`` through the shared session and return assistant text."""
|
|
120
|
+
if not self._active or self._session is None:
|
|
121
|
+
raise RuntimeError("CopilotAgentSession is not active")
|
|
122
|
+
if not isinstance(prompt, str):
|
|
123
|
+
raise TypeError("prompt must be a str")
|
|
124
|
+
if timeout is not None and timeout <= 0:
|
|
125
|
+
raise ValueError("timeout must be greater than 0")
|
|
126
|
+
|
|
127
|
+
effective_timeout = self.timeout if timeout is None else timeout
|
|
128
|
+
if self._send_lock is None:
|
|
129
|
+
self._send_lock = asyncio.Lock()
|
|
130
|
+
async with self._send_lock:
|
|
131
|
+
response = await self._session.send_and_wait(
|
|
132
|
+
prompt,
|
|
133
|
+
timeout=effective_timeout,
|
|
134
|
+
)
|
|
135
|
+
return self._response_text(response)
|
|
136
|
+
|
|
137
|
+
def on(self, handler: Callable[[Any], None]) -> Callable[[], None]:
|
|
138
|
+
"""Subscribe to Copilot session events.
|
|
139
|
+
|
|
140
|
+
The returned callable unsubscribes ``handler``. Handlers may be called
|
|
141
|
+
from the session's event-loop thread when using the synchronous context
|
|
142
|
+
manager.
|
|
143
|
+
"""
|
|
144
|
+
if not callable(handler):
|
|
145
|
+
raise TypeError("handler must be callable")
|
|
146
|
+
with self._event_lock:
|
|
147
|
+
self._event_handlers.append(handler)
|
|
148
|
+
|
|
149
|
+
def unsubscribe() -> None:
|
|
150
|
+
with self._event_lock:
|
|
151
|
+
if handler in self._event_handlers:
|
|
152
|
+
self._event_handlers.remove(handler)
|
|
153
|
+
|
|
154
|
+
return unsubscribe
|
|
155
|
+
|
|
156
|
+
def handler(self) -> TaskHandler:
|
|
157
|
+
"""Return a synchronous AGENT task handler backed by this session."""
|
|
158
|
+
|
|
159
|
+
def run(context: TaskContext) -> str:
|
|
160
|
+
context.raise_if_cancelled()
|
|
161
|
+
if not self._sync_active or self._loop is None:
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
"CopilotAgentSession.handler() requires an active sync context",
|
|
164
|
+
)
|
|
165
|
+
with self._handler_lock:
|
|
166
|
+
context.raise_if_cancelled()
|
|
167
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
168
|
+
self.send_and_wait(context.payload, timeout=context.timeout),
|
|
169
|
+
self._loop,
|
|
170
|
+
)
|
|
171
|
+
while True:
|
|
172
|
+
try:
|
|
173
|
+
result = future.result(timeout=0.05)
|
|
174
|
+
except FutureTimeoutError:
|
|
175
|
+
if context.cancelled:
|
|
176
|
+
future.cancel()
|
|
177
|
+
self._abort_active()
|
|
178
|
+
raise TaskCancelled(
|
|
179
|
+
f"Task {context.id!r} was cancelled",
|
|
180
|
+
) from None
|
|
181
|
+
continue
|
|
182
|
+
except FutureCancelledError as error:
|
|
183
|
+
raise TaskCancelled(
|
|
184
|
+
f"Task {context.id!r} was cancelled",
|
|
185
|
+
) from error
|
|
186
|
+
context.raise_if_cancelled()
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
return run
|
|
190
|
+
|
|
191
|
+
async def _open_async(self) -> None:
|
|
192
|
+
"""Create the SDK client/session pair."""
|
|
193
|
+
from copilot import CopilotClient
|
|
194
|
+
from copilot.session import PermissionHandler
|
|
195
|
+
|
|
196
|
+
session_options = dict(self._session_options)
|
|
197
|
+
session_options.setdefault(
|
|
198
|
+
"on_permission_request",
|
|
199
|
+
PermissionHandler.approve_all,
|
|
200
|
+
)
|
|
201
|
+
session_options["model"] = self.model
|
|
202
|
+
if self.reasoning_effort is not None:
|
|
203
|
+
session_options["reasoning_effort"] = self.reasoning_effort
|
|
204
|
+
if self.working_directory is not None:
|
|
205
|
+
session_options["working_directory"] = self.working_directory
|
|
206
|
+
session_options["on_event"] = self._dispatch_event
|
|
207
|
+
|
|
208
|
+
client = CopilotClient()
|
|
209
|
+
entered_client = await client.__aenter__()
|
|
210
|
+
try:
|
|
211
|
+
session = await entered_client.create_session(**session_options)
|
|
212
|
+
entered_session = await session.__aenter__()
|
|
213
|
+
except BaseException:
|
|
214
|
+
await entered_client.__aexit__(None, None, None)
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
self._client = entered_client
|
|
218
|
+
self._session = entered_session
|
|
219
|
+
self._send_lock = asyncio.Lock()
|
|
220
|
+
self._active = True
|
|
221
|
+
|
|
222
|
+
async def _close_async(
|
|
223
|
+
self,
|
|
224
|
+
exc_type: type[BaseException] | None = None,
|
|
225
|
+
exc: BaseException | None = None,
|
|
226
|
+
traceback: object | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Close active SDK resources in reverse creation order."""
|
|
229
|
+
session = self._session
|
|
230
|
+
client = self._client
|
|
231
|
+
self._session = None
|
|
232
|
+
self._client = None
|
|
233
|
+
self._send_lock = None
|
|
234
|
+
self._active = False
|
|
235
|
+
|
|
236
|
+
close_error: BaseException | None = None
|
|
237
|
+
if session is not None:
|
|
238
|
+
try:
|
|
239
|
+
await session.__aexit__(exc_type, exc, traceback)
|
|
240
|
+
except BaseException as error:
|
|
241
|
+
close_error = error
|
|
242
|
+
if client is not None:
|
|
243
|
+
try:
|
|
244
|
+
await client.__aexit__(exc_type, exc, traceback)
|
|
245
|
+
except BaseException as error:
|
|
246
|
+
if close_error is None:
|
|
247
|
+
close_error = error
|
|
248
|
+
if close_error is not None:
|
|
249
|
+
raise close_error
|
|
250
|
+
|
|
251
|
+
async def _abort_active_async(self) -> None:
|
|
252
|
+
"""Abort the active Copilot turn if the SDK session supports it."""
|
|
253
|
+
session = self._session
|
|
254
|
+
abort = getattr(session, "abort", None)
|
|
255
|
+
if callable(abort):
|
|
256
|
+
await abort()
|
|
257
|
+
|
|
258
|
+
def _abort_active(self) -> None:
|
|
259
|
+
"""Best-effort abort for a cancelled synchronous handler call."""
|
|
260
|
+
if self._loop is None:
|
|
261
|
+
return
|
|
262
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
263
|
+
self._abort_active_async(),
|
|
264
|
+
self._loop,
|
|
265
|
+
)
|
|
266
|
+
future.result(timeout=5)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _response_text(response: Any) -> str:
|
|
270
|
+
"""Normalize a Copilot SDK response event to assistant text."""
|
|
271
|
+
from copilot.generated.session_events import AssistantMessageData
|
|
272
|
+
|
|
273
|
+
if response is None or not isinstance(response.data, AssistantMessageData):
|
|
274
|
+
return ""
|
|
275
|
+
return response.data.content or ""
|
|
276
|
+
|
|
277
|
+
def _dispatch_event(self, event: Any) -> None:
|
|
278
|
+
"""Fan out one SDK event to registered session subscribers."""
|
|
279
|
+
with self._event_lock:
|
|
280
|
+
handlers = list(self._event_handlers)
|
|
281
|
+
for handler in handlers:
|
|
282
|
+
try:
|
|
283
|
+
handler(event)
|
|
284
|
+
except BaseException as error:
|
|
285
|
+
self.event_errors.append(error)
|
|
286
|
+
|
|
287
|
+
def _start_loop_thread(self) -> None:
|
|
288
|
+
"""Start the background event loop used by the sync API."""
|
|
289
|
+
self._thread_ready.clear()
|
|
290
|
+
|
|
291
|
+
def run_loop() -> None:
|
|
292
|
+
loop = asyncio.new_event_loop()
|
|
293
|
+
asyncio.set_event_loop(loop)
|
|
294
|
+
self._loop = loop
|
|
295
|
+
self._thread_ready.set()
|
|
296
|
+
try:
|
|
297
|
+
loop.run_forever()
|
|
298
|
+
finally:
|
|
299
|
+
loop.close()
|
|
300
|
+
self._loop = None
|
|
301
|
+
|
|
302
|
+
self._thread = Thread(
|
|
303
|
+
target=run_loop,
|
|
304
|
+
name="ttasks-copilot-agent-session",
|
|
305
|
+
daemon=True,
|
|
306
|
+
)
|
|
307
|
+
self._thread.start()
|
|
308
|
+
self._thread_ready.wait()
|
|
309
|
+
|
|
310
|
+
def _stop_loop_thread(self) -> None:
|
|
311
|
+
"""Stop and join the background event-loop thread."""
|
|
312
|
+
loop = self._loop
|
|
313
|
+
thread = self._thread
|
|
314
|
+
if loop is not None:
|
|
315
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
316
|
+
if thread is not None:
|
|
317
|
+
thread.join(timeout=5)
|
|
318
|
+
self._thread = None
|
|
319
|
+
self._thread_ready.clear()
|
|
320
|
+
|
|
321
|
+
def _run_on_loop(self, coroutine: Any) -> Any:
|
|
322
|
+
"""Run ``coroutine`` on the sync background loop and return its result."""
|
|
323
|
+
if self._loop is None:
|
|
324
|
+
raise RuntimeError("CopilotAgentSession event loop is not running")
|
|
325
|
+
future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
326
|
+
return future.result()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
__all__ = ["CopilotAgentSession"]
|