yee88 0.1.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.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,619 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass, field
6
+
7
+ import anyio
8
+
9
+ from .context import RunContext
10
+ from .logging import bind_run_context, get_logger
11
+ from .model import CompletedEvent, ResumeToken, StartedEvent, TakopiEvent
12
+ from .presenter import Presenter
13
+ from .markdown import render_event_cli
14
+ from .runner import Runner
15
+ from .progress import ProgressTracker
16
+ from .transport import (
17
+ ChannelId,
18
+ MessageId,
19
+ MessageRef,
20
+ RenderedMessage,
21
+ SendOptions,
22
+ ThreadId,
23
+ Transport,
24
+ )
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ def _log_runner_event(evt: TakopiEvent) -> None:
30
+ for line in render_event_cli(evt):
31
+ logger.debug(
32
+ "runner.event.cli",
33
+ line=line,
34
+ event_type=getattr(evt, "type", None),
35
+ engine=getattr(evt, "engine", None),
36
+ )
37
+
38
+
39
+ def _strip_resume_lines(text: str, *, is_resume_line: Callable[[str], bool]) -> str:
40
+ prompt = "\n".join(
41
+ line for line in text.splitlines() if not is_resume_line(line)
42
+ ).strip()
43
+ return prompt or "continue"
44
+
45
+
46
+ def _flatten_exception_group(error: BaseException) -> list[BaseException]:
47
+ if isinstance(error, BaseExceptionGroup):
48
+ flattened: list[BaseException] = []
49
+ for exc in error.exceptions:
50
+ flattened.extend(_flatten_exception_group(exc))
51
+ return flattened
52
+ return [error]
53
+
54
+
55
+ def _format_error(error: Exception) -> str:
56
+ cancel_exc = anyio.get_cancelled_exc_class()
57
+ flattened = [
58
+ exc
59
+ for exc in _flatten_exception_group(error)
60
+ if not isinstance(exc, cancel_exc)
61
+ ]
62
+ if len(flattened) == 1:
63
+ return str(flattened[0]) or flattened[0].__class__.__name__
64
+ if not flattened:
65
+ return str(error) or error.__class__.__name__
66
+ messages = [str(exc) for exc in flattened if str(exc)]
67
+ if not messages:
68
+ return str(error) or error.__class__.__name__
69
+ if len(messages) == 1:
70
+ return messages[0]
71
+ return "\n".join(messages)
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class IncomingMessage:
76
+ channel_id: ChannelId
77
+ message_id: MessageId
78
+ text: str
79
+ reply_to: MessageRef | None = None
80
+ thread_id: ThreadId | None = None
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class ExecBridgeConfig:
85
+ transport: Transport
86
+ presenter: Presenter
87
+ final_notify: bool
88
+
89
+
90
+ @dataclass(slots=True)
91
+ class RunningTask:
92
+ resume: ResumeToken | None = None
93
+ resume_ready: anyio.Event = field(default_factory=anyio.Event)
94
+ cancel_requested: anyio.Event = field(default_factory=anyio.Event)
95
+ done: anyio.Event = field(default_factory=anyio.Event)
96
+ context: RunContext | None = None
97
+
98
+
99
+ RunningTasks = dict[MessageRef, RunningTask]
100
+
101
+
102
+ async def _send_or_edit_message(
103
+ transport: Transport,
104
+ *,
105
+ channel_id: ChannelId,
106
+ message: RenderedMessage,
107
+ edit_ref: MessageRef | None = None,
108
+ reply_to: MessageRef | None = None,
109
+ notify: bool = True,
110
+ replace_ref: MessageRef | None = None,
111
+ thread_id: ThreadId | None = None,
112
+ ) -> tuple[MessageRef | None, bool]:
113
+ msg = message
114
+ followups = message.extra.get("followups")
115
+ if followups:
116
+ extra = dict(message.extra)
117
+ if reply_to is not None:
118
+ extra.setdefault("followup_reply_to_message_id", reply_to.message_id)
119
+ if thread_id is not None:
120
+ extra.setdefault("followup_thread_id", thread_id)
121
+ extra.setdefault("followup_notify", notify)
122
+ msg = RenderedMessage(text=message.text, extra=extra)
123
+ if edit_ref is not None:
124
+ logger.debug(
125
+ "transport.edit_message",
126
+ channel_id=edit_ref.channel_id,
127
+ message_id=edit_ref.message_id,
128
+ rendered=msg.text,
129
+ )
130
+ edited = await transport.edit(ref=edit_ref, message=msg)
131
+ if edited is not None:
132
+ return edited, True
133
+
134
+ logger.debug(
135
+ "transport.send_message",
136
+ channel_id=channel_id,
137
+ reply_to_message_id=reply_to.message_id if reply_to else None,
138
+ rendered=msg.text,
139
+ )
140
+ sent = await transport.send(
141
+ channel_id=channel_id,
142
+ message=msg,
143
+ options=SendOptions(
144
+ reply_to=reply_to,
145
+ notify=notify,
146
+ replace=replace_ref,
147
+ thread_id=thread_id,
148
+ ),
149
+ )
150
+ return sent, False
151
+
152
+
153
+ class ProgressEdits:
154
+ def __init__(
155
+ self,
156
+ *,
157
+ transport: Transport,
158
+ presenter: Presenter,
159
+ channel_id: ChannelId,
160
+ progress_ref: MessageRef | None,
161
+ tracker: ProgressTracker,
162
+ started_at: float,
163
+ clock: Callable[[], float],
164
+ last_rendered: RenderedMessage | None,
165
+ resume_formatter: Callable[[ResumeToken], str] | None = None,
166
+ label: str = "working",
167
+ context_line: str | None = None,
168
+ ) -> None:
169
+ self.transport = transport
170
+ self.presenter = presenter
171
+ self.channel_id = channel_id
172
+ self.progress_ref = progress_ref
173
+ self.tracker = tracker
174
+ self.started_at = started_at
175
+ self.clock = clock
176
+ self.last_rendered = last_rendered
177
+ self.resume_formatter = resume_formatter
178
+ self.label = label
179
+ self.context_line = context_line
180
+ self.event_seq = 0
181
+ self.rendered_seq = 0
182
+ self.signal_send, self.signal_recv = anyio.create_memory_object_stream(1)
183
+
184
+ async def run(self) -> None:
185
+ if self.progress_ref is None:
186
+ return
187
+ while True:
188
+ while self.rendered_seq == self.event_seq:
189
+ try:
190
+ await self.signal_recv.receive()
191
+ except anyio.EndOfStream:
192
+ return
193
+
194
+ seq_at_render = self.event_seq
195
+ now = self.clock()
196
+ state = self.tracker.snapshot(
197
+ resume_formatter=self.resume_formatter,
198
+ context_line=self.context_line,
199
+ )
200
+ rendered = self.presenter.render_progress(
201
+ state, elapsed_s=now - self.started_at, label=self.label
202
+ )
203
+ if rendered != self.last_rendered:
204
+ logger.debug(
205
+ "transport.edit_message",
206
+ channel_id=self.channel_id,
207
+ message_id=self.progress_ref.message_id,
208
+ rendered=rendered.text,
209
+ )
210
+ edited = await self.transport.edit(
211
+ ref=self.progress_ref,
212
+ message=rendered,
213
+ wait=False,
214
+ )
215
+ if edited is not None:
216
+ self.last_rendered = rendered
217
+
218
+ self.rendered_seq = seq_at_render
219
+
220
+ async def on_event(self, evt: TakopiEvent) -> None:
221
+ if not self.tracker.note_event(evt):
222
+ return
223
+ if self.progress_ref is None:
224
+ return
225
+ self.event_seq += 1
226
+ try:
227
+ self.signal_send.send_nowait(None)
228
+ except anyio.WouldBlock:
229
+ pass
230
+ except (anyio.BrokenResourceError, anyio.ClosedResourceError):
231
+ pass
232
+
233
+
234
+ @dataclass(frozen=True, slots=True)
235
+ class ProgressMessageState:
236
+ ref: MessageRef | None
237
+ last_rendered: RenderedMessage | None
238
+
239
+
240
+ async def send_initial_progress(
241
+ cfg: ExecBridgeConfig,
242
+ *,
243
+ channel_id: ChannelId,
244
+ reply_to: MessageRef,
245
+ label: str,
246
+ tracker: ProgressTracker,
247
+ progress_ref: MessageRef | None = None,
248
+ resume_formatter: Callable[[ResumeToken], str] | None = None,
249
+ context_line: str | None = None,
250
+ thread_id: ThreadId | None = None,
251
+ ) -> ProgressMessageState:
252
+ last_rendered: RenderedMessage | None = None
253
+
254
+ state = tracker.snapshot(
255
+ resume_formatter=resume_formatter,
256
+ context_line=context_line,
257
+ )
258
+ initial_rendered = cfg.presenter.render_progress(
259
+ state,
260
+ elapsed_s=0.0,
261
+ label=label,
262
+ )
263
+ sent_ref, _ = await _send_or_edit_message(
264
+ cfg.transport,
265
+ channel_id=channel_id,
266
+ message=initial_rendered,
267
+ edit_ref=progress_ref,
268
+ reply_to=reply_to,
269
+ notify=False,
270
+ replace_ref=progress_ref,
271
+ thread_id=thread_id,
272
+ )
273
+ if sent_ref is not None:
274
+ last_rendered = initial_rendered
275
+ logger.debug(
276
+ "progress.sent",
277
+ channel_id=sent_ref.channel_id,
278
+ message_id=sent_ref.message_id,
279
+ )
280
+
281
+ return ProgressMessageState(
282
+ ref=sent_ref,
283
+ last_rendered=last_rendered,
284
+ )
285
+
286
+
287
+ @dataclass(slots=True)
288
+ class RunOutcome:
289
+ cancelled: bool = False
290
+ completed: CompletedEvent | None = None
291
+ resume: ResumeToken | None = None
292
+
293
+
294
+ async def run_runner_with_cancel(
295
+ runner: Runner,
296
+ *,
297
+ prompt: str,
298
+ resume_token: ResumeToken | None,
299
+ edits: ProgressEdits,
300
+ running_task: RunningTask | None,
301
+ on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None,
302
+ ) -> RunOutcome:
303
+ outcome = RunOutcome()
304
+ async with anyio.create_task_group() as tg:
305
+
306
+ async def run_runner() -> None:
307
+ try:
308
+ async for evt in runner.run(prompt, resume_token):
309
+ _log_runner_event(evt)
310
+ if isinstance(evt, StartedEvent):
311
+ outcome.resume = evt.resume
312
+ bind_run_context(resume=evt.resume.value)
313
+ if running_task is not None and running_task.resume is None:
314
+ running_task.resume = evt.resume
315
+ try:
316
+ if on_thread_known is not None:
317
+ await on_thread_known(evt.resume, running_task.done)
318
+ finally:
319
+ running_task.resume_ready.set()
320
+ elif isinstance(evt, CompletedEvent):
321
+ outcome.resume = evt.resume or outcome.resume
322
+ outcome.completed = evt
323
+ await edits.on_event(evt)
324
+ finally:
325
+ tg.cancel_scope.cancel()
326
+
327
+ async def wait_cancel(task: RunningTask) -> None:
328
+ await task.cancel_requested.wait()
329
+ outcome.cancelled = True
330
+ tg.cancel_scope.cancel()
331
+
332
+ tg.start_soon(run_runner)
333
+ if running_task is not None:
334
+ tg.start_soon(wait_cancel, running_task)
335
+
336
+ return outcome
337
+
338
+
339
+ def sync_resume_token(
340
+ tracker: ProgressTracker, resume: ResumeToken | None
341
+ ) -> ResumeToken | None:
342
+ resume = resume or tracker.resume
343
+ tracker.set_resume(resume)
344
+ return resume
345
+
346
+
347
+ async def send_result_message(
348
+ cfg: ExecBridgeConfig,
349
+ *,
350
+ channel_id: ChannelId,
351
+ reply_to: MessageRef,
352
+ progress_ref: MessageRef | None,
353
+ message: RenderedMessage,
354
+ notify: bool,
355
+ edit_ref: MessageRef | None,
356
+ replace_ref: MessageRef | None = None,
357
+ delete_tag: str = "final",
358
+ thread_id: ThreadId | None = None,
359
+ ) -> None:
360
+ final_msg, edited = await _send_or_edit_message(
361
+ cfg.transport,
362
+ channel_id=channel_id,
363
+ message=message,
364
+ edit_ref=edit_ref,
365
+ reply_to=reply_to,
366
+ notify=notify,
367
+ replace_ref=replace_ref,
368
+ thread_id=thread_id,
369
+ )
370
+ if final_msg is None:
371
+ return
372
+ if (
373
+ progress_ref is not None
374
+ and (edit_ref is None or not edited)
375
+ and replace_ref is None
376
+ ):
377
+ logger.debug(
378
+ "transport.delete_message",
379
+ channel_id=progress_ref.channel_id,
380
+ message_id=progress_ref.message_id,
381
+ tag=delete_tag,
382
+ )
383
+ await cfg.transport.delete(ref=progress_ref)
384
+
385
+
386
+ async def handle_message(
387
+ cfg: ExecBridgeConfig,
388
+ *,
389
+ runner: Runner,
390
+ incoming: IncomingMessage,
391
+ resume_token: ResumeToken | None,
392
+ context: RunContext | None = None,
393
+ context_line: str | None = None,
394
+ strip_resume_line: Callable[[str], bool] | None = None,
395
+ running_tasks: RunningTasks | None = None,
396
+ on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]]
397
+ | None = None,
398
+ progress_ref: MessageRef | None = None,
399
+ clock: Callable[[], float] = time.monotonic,
400
+ ) -> None:
401
+ logger.info(
402
+ "handle.incoming",
403
+ channel_id=incoming.channel_id,
404
+ user_msg_id=incoming.message_id,
405
+ resume=resume_token.value if resume_token else None,
406
+ text=incoming.text,
407
+ )
408
+ started_at = clock()
409
+ is_resume_line = runner.is_resume_line
410
+ resume_strip = strip_resume_line or is_resume_line
411
+ runner_text = _strip_resume_lines(incoming.text, is_resume_line=resume_strip)
412
+
413
+ progress_tracker = ProgressTracker(engine=runner.engine)
414
+
415
+ user_ref = MessageRef(
416
+ channel_id=incoming.channel_id,
417
+ message_id=incoming.message_id,
418
+ )
419
+ progress_state = await send_initial_progress(
420
+ cfg,
421
+ channel_id=incoming.channel_id,
422
+ reply_to=user_ref,
423
+ label="starting",
424
+ tracker=progress_tracker,
425
+ progress_ref=progress_ref,
426
+ resume_formatter=runner.format_resume,
427
+ context_line=context_line,
428
+ thread_id=incoming.thread_id,
429
+ )
430
+ progress_ref = progress_state.ref
431
+
432
+ edits = ProgressEdits(
433
+ transport=cfg.transport,
434
+ presenter=cfg.presenter,
435
+ channel_id=incoming.channel_id,
436
+ progress_ref=progress_ref,
437
+ tracker=progress_tracker,
438
+ started_at=started_at,
439
+ clock=clock,
440
+ last_rendered=progress_state.last_rendered,
441
+ resume_formatter=runner.format_resume,
442
+ context_line=context_line,
443
+ )
444
+
445
+ running_task: RunningTask | None = None
446
+ if running_tasks is not None and progress_ref is not None:
447
+ running_task = RunningTask(context=context)
448
+ running_tasks[progress_ref] = running_task
449
+
450
+ cancel_exc_type = anyio.get_cancelled_exc_class()
451
+ edits_scope = anyio.CancelScope()
452
+
453
+ async def run_edits() -> None:
454
+ try:
455
+ with edits_scope:
456
+ await edits.run()
457
+ except cancel_exc_type:
458
+ # Edits are best-effort; cancellation should not bubble into the task group.
459
+ return
460
+
461
+ outcome = RunOutcome()
462
+ error: Exception | None = None
463
+
464
+ async with anyio.create_task_group() as tg:
465
+ if progress_ref is not None:
466
+ tg.start_soon(run_edits)
467
+
468
+ try:
469
+ outcome = await run_runner_with_cancel(
470
+ runner,
471
+ prompt=runner_text,
472
+ resume_token=resume_token,
473
+ edits=edits,
474
+ running_task=running_task,
475
+ on_thread_known=on_thread_known,
476
+ )
477
+ except Exception as exc:
478
+ error = exc
479
+ logger.exception(
480
+ "handle.runner_failed",
481
+ error=str(exc),
482
+ error_type=exc.__class__.__name__,
483
+ )
484
+ finally:
485
+ if running_task is not None and running_tasks is not None:
486
+ running_task.done.set()
487
+ if progress_ref is not None:
488
+ running_tasks.pop(progress_ref, None)
489
+ if not outcome.cancelled and error is None:
490
+ # Give pending progress edits a chance to flush if they're ready.
491
+ await anyio.sleep(0)
492
+ edits_scope.cancel()
493
+
494
+ elapsed = clock() - started_at
495
+
496
+ if error is not None:
497
+ sync_resume_token(progress_tracker, outcome.resume)
498
+ err_body = _format_error(error)
499
+ state = progress_tracker.snapshot(
500
+ resume_formatter=runner.format_resume,
501
+ context_line=context_line,
502
+ )
503
+ final_rendered = cfg.presenter.render_final(
504
+ state,
505
+ elapsed_s=elapsed,
506
+ status="error",
507
+ answer=err_body,
508
+ )
509
+ logger.debug(
510
+ "handle.error.rendered",
511
+ error=err_body,
512
+ rendered=final_rendered.text,
513
+ )
514
+ await send_result_message(
515
+ cfg,
516
+ channel_id=incoming.channel_id,
517
+ reply_to=user_ref,
518
+ progress_ref=progress_ref,
519
+ message=final_rendered,
520
+ notify=False,
521
+ edit_ref=progress_ref,
522
+ replace_ref=progress_ref,
523
+ delete_tag="error",
524
+ thread_id=incoming.thread_id,
525
+ )
526
+ return
527
+
528
+ if outcome.cancelled:
529
+ resume = sync_resume_token(progress_tracker, outcome.resume)
530
+ logger.info(
531
+ "handle.cancelled",
532
+ resume=resume.value if resume else None,
533
+ elapsed_s=elapsed,
534
+ )
535
+ state = progress_tracker.snapshot(
536
+ resume_formatter=runner.format_resume,
537
+ context_line=context_line,
538
+ )
539
+ final_rendered = cfg.presenter.render_progress(
540
+ state,
541
+ elapsed_s=elapsed,
542
+ label="`cancelled`",
543
+ )
544
+ await send_result_message(
545
+ cfg,
546
+ channel_id=incoming.channel_id,
547
+ reply_to=user_ref,
548
+ progress_ref=progress_ref,
549
+ message=final_rendered,
550
+ notify=False,
551
+ edit_ref=progress_ref,
552
+ replace_ref=progress_ref,
553
+ delete_tag="cancel",
554
+ thread_id=incoming.thread_id,
555
+ )
556
+ return
557
+
558
+ if outcome.completed is None:
559
+ raise RuntimeError("runner finished without a completed event")
560
+
561
+ completed = outcome.completed
562
+ run_ok = completed.ok
563
+ run_error = completed.error
564
+
565
+ final_answer = completed.answer
566
+ if run_ok is False and run_error:
567
+ if final_answer.strip():
568
+ final_answer = f"{final_answer}\n\n{run_error}"
569
+ else:
570
+ final_answer = str(run_error)
571
+
572
+ status = (
573
+ "error" if run_ok is False else ("done" if final_answer.strip() else "error")
574
+ )
575
+ resume_value = None
576
+ resume_token = completed.resume or outcome.resume
577
+ if resume_token is not None:
578
+ resume_value = resume_token.value
579
+ logger.info(
580
+ "runner.completed",
581
+ ok=run_ok,
582
+ error=run_error,
583
+ answer_len=len(final_answer or ""),
584
+ elapsed_s=round(elapsed, 2),
585
+ action_count=progress_tracker.action_count,
586
+ resume=resume_value,
587
+ )
588
+ sync_resume_token(progress_tracker, completed.resume or outcome.resume)
589
+ state = progress_tracker.snapshot(
590
+ resume_formatter=runner.format_resume,
591
+ context_line=context_line,
592
+ )
593
+ final_rendered = cfg.presenter.render_final(
594
+ state,
595
+ elapsed_s=elapsed,
596
+ status=status,
597
+ answer=final_answer,
598
+ )
599
+ logger.debug(
600
+ "handle.final.rendered",
601
+ rendered=final_rendered.text,
602
+ status=status,
603
+ )
604
+
605
+ can_edit_final = progress_ref is not None
606
+ edit_ref = None if cfg.final_notify or not can_edit_final else progress_ref
607
+
608
+ await send_result_message(
609
+ cfg,
610
+ channel_id=incoming.channel_id,
611
+ reply_to=user_ref,
612
+ progress_ref=progress_ref,
613
+ message=final_rendered,
614
+ notify=cfg.final_notify,
615
+ edit_ref=edit_ref,
616
+ replace_ref=progress_ref,
617
+ delete_tag="final",
618
+ thread_id=incoming.thread_id,
619
+ )
@@ -0,0 +1 @@
1
+ """Runner implementations."""