kiwi-code 0.0.502__tar.gz → 0.0.504__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.
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/PKG-INFO +1 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/pyproject.toml +1 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/cli.py +3 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/client.py +55 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/commands.py +1 -0
- kiwi_code-0.0.504/src/kiwi_cli/io_threads.py +35 -0
- kiwi_code-0.0.504/src/kiwi_cli/run_activity.py +473 -0
- kiwi_code-0.0.504/src/kiwi_cli/subagents.py +361 -0
- kiwi_code-0.0.504/src/kiwi_tui/latex_unicode.py +224 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/main.py +73 -10
- kiwi_code-0.0.504/src/kiwi_tui/screens/term_activity_view.py +290 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard.py +914 -150
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard_css.py +100 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard_helpers.py +163 -17
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard_widgets.py +44 -3
- kiwi_code-0.0.504/src/kiwi_tui/screens/term_status_line.py +107 -0
- kiwi_code-0.0.504/src/kiwi_tui/screens/term_subagent_widgets.py +776 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/slash_commands.py +3 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/widgets.py +3 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/conftest.py +12 -0
- kiwi_code-0.0.504/tests/test_command_tips.py +77 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_human_questions.py +16 -5
- kiwi_code-0.0.504/tests/test_io_threads.py +43 -0
- kiwi_code-0.0.504/tests/test_math_and_tables.py +94 -0
- kiwi_code-0.0.504/tests/test_run_activity.py +686 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_run_steering.py +10 -5
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_slash_commands.py +5 -2
- kiwi_code-0.0.504/tests/test_status_line.py +96 -0
- kiwi_code-0.0.504/tests/test_subagents.py +454 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_term_dashboard_ui.py +141 -6
- kiwi_code-0.0.504/tests/test_toasts.py +49 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_tui_palette.py +42 -3
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/uv.lock +1 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.gitignore +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.python-version +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/Makefile +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/README.md +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/common/banner.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/edition.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/human_questions.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/cli.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/handler.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/sessions.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/ui.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/test_hello.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_imports_on_windows.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_stats_command.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_worktrees.py +0 -0
|
@@ -137,10 +137,13 @@ def _get_client() -> AuthenticatedClient:
|
|
|
137
137
|
typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
|
|
138
138
|
raise typer.Exit(code=1)
|
|
139
139
|
|
|
140
|
+
from kiwi_cli.client import API_TIMEOUT
|
|
141
|
+
|
|
140
142
|
return AuthenticatedClient(
|
|
141
143
|
base_url=_backend_url(),
|
|
142
144
|
token=str(access),
|
|
143
145
|
raise_on_unexpected_status=False,
|
|
146
|
+
timeout=API_TIMEOUT,
|
|
144
147
|
)
|
|
145
148
|
|
|
146
149
|
|
|
@@ -16,6 +16,7 @@ from pykiwiai.api.actions import (
|
|
|
16
16
|
async_run_action_v1_actions_id_async_run_post,
|
|
17
17
|
)
|
|
18
18
|
from pykiwiai.api.action_results import get_action_result_v1_action_results_id_get
|
|
19
|
+
from pykiwiai.api.subagents import list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get
|
|
19
20
|
from pykiwiai.models.human_answers_request import HumanAnswersRequest
|
|
20
21
|
from pykiwiai.models.body_return_user_and_session_v1_auth_post import (
|
|
21
22
|
BodyReturnUserAndSessionV1AuthPost,
|
|
@@ -28,6 +29,11 @@ from .models import AuthTokens, LoginCredentials
|
|
|
28
29
|
from .human_questions import WAITING_STATUS, human_event_type, parse_questions
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
# Every API call gives up in the end: a request that never answers would otherwise hold its
|
|
33
|
+
# thread, and the app, open for ever. Uploads get the long read; the live stream has its own.
|
|
34
|
+
API_TIMEOUT = httpx.Timeout(120.0, connect=15.0)
|
|
35
|
+
|
|
36
|
+
|
|
31
37
|
class AutobotsClientWrapper:
|
|
32
38
|
"""Wrapper around autobots_client for API calls."""
|
|
33
39
|
|
|
@@ -56,12 +62,14 @@ class AutobotsClientWrapper:
|
|
|
56
62
|
base_url=base_url,
|
|
57
63
|
token=token,
|
|
58
64
|
raise_on_unexpected_status=False,
|
|
65
|
+
timeout=API_TIMEOUT,
|
|
59
66
|
)
|
|
60
67
|
logger.info(f"Initialized authenticated client for {base_url}")
|
|
61
68
|
else:
|
|
62
69
|
self.client = Client(
|
|
63
70
|
base_url=base_url,
|
|
64
71
|
raise_on_unexpected_status=False,
|
|
72
|
+
timeout=API_TIMEOUT,
|
|
65
73
|
)
|
|
66
74
|
logger.info(f"Initialized unauthenticated client for {base_url}")
|
|
67
75
|
|
|
@@ -76,6 +84,7 @@ class AutobotsClientWrapper:
|
|
|
76
84
|
base_url=self.base_url,
|
|
77
85
|
token=self.access_token,
|
|
78
86
|
raise_on_unexpected_status=False,
|
|
87
|
+
timeout=API_TIMEOUT,
|
|
79
88
|
)
|
|
80
89
|
logger.info("Client updated with new access token")
|
|
81
90
|
|
|
@@ -640,6 +649,7 @@ class AutobotsClientWrapper:
|
|
|
640
649
|
topic: str | None = None,
|
|
641
650
|
stop_on_terminal_status: bool = True,
|
|
642
651
|
plain_text_type: str = "status",
|
|
652
|
+
on_open: Callable[[], None] | None = None,
|
|
643
653
|
) -> None:
|
|
644
654
|
"""Stream action run updates via Server-Sent Events.
|
|
645
655
|
|
|
@@ -662,6 +672,8 @@ class AutobotsClientWrapper:
|
|
|
662
672
|
`status` field. Disable for topics that don't send status (e.g. *-text).
|
|
663
673
|
plain_text_type: When a frame is not valid JSON, wrap it as
|
|
664
674
|
`{'type': plain_text_type, 'text': <payload>}`.
|
|
675
|
+
on_open: Called once the stream is connected, e.g. to read back what was
|
|
676
|
+
published before it was.
|
|
665
677
|
"""
|
|
666
678
|
topic = topic or run_id
|
|
667
679
|
sse_endpoint = f"{self.base_url}/v1/server_sent_events/stream/{topic}"
|
|
@@ -683,6 +695,11 @@ class AutobotsClientWrapper:
|
|
|
683
695
|
if response.status_code != 200:
|
|
684
696
|
logger.error(f"SSE connection failed with status {response.status_code}")
|
|
685
697
|
return
|
|
698
|
+
if on_open is not None:
|
|
699
|
+
try:
|
|
700
|
+
on_open()
|
|
701
|
+
except Exception as e:
|
|
702
|
+
logger.warning(f"SSE on_open failed: {e}")
|
|
686
703
|
|
|
687
704
|
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
688
705
|
buffer = ""
|
|
@@ -811,6 +828,44 @@ class AutobotsClientWrapper:
|
|
|
811
828
|
logger.error(f"Error answering questions on run {run_id}: {e}")
|
|
812
829
|
return 0, None, f"Error sending answer: {e}"
|
|
813
830
|
|
|
831
|
+
def list_subagents(self, run_id: str) -> tuple[int, Optional[dict], str]:
|
|
832
|
+
"""The subagents `run_id` has spawned, once each with its status:
|
|
833
|
+
`GET /v1/subagents/runs/{run_id}`.
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
Tuple of (http_status, body, message); body is the `SubagentsOfRun` dict on 200.
|
|
837
|
+
"""
|
|
838
|
+
try:
|
|
839
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
840
|
+
return 401, None, "Authentication required"
|
|
841
|
+
response = list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get.sync_detailed(
|
|
842
|
+
parent_action_run_id=run_id, client=self.client
|
|
843
|
+
)
|
|
844
|
+
code = int(response.status_code)
|
|
845
|
+
if code == 200 and response.parsed is not None and hasattr(response.parsed, "subagents"):
|
|
846
|
+
return 200, response.parsed.to_dict(), "ok"
|
|
847
|
+
return code, None, f"HTTP {code}"
|
|
848
|
+
except Exception as e:
|
|
849
|
+
logger.warning(f"Listing subagents of run {run_id} failed: {e}")
|
|
850
|
+
return 0, None, f"Error listing subagents: {e}"
|
|
851
|
+
|
|
852
|
+
def get_run_activity(self, run_id: str) -> tuple[int, list[dict]]:
|
|
853
|
+
"""A run's recent typed activity events, oldest first
|
|
854
|
+
(`GET /v1/server_sent_events/activity/{run_id}`), for a client that subscribes to
|
|
855
|
+
`{run_id}-activity` late. A backend without the activity stream answers 404.
|
|
856
|
+
"""
|
|
857
|
+
try:
|
|
858
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
859
|
+
return 401, []
|
|
860
|
+
response = self.client.get_httpx_client().get(f"/v1/server_sent_events/activity/{run_id}")
|
|
861
|
+
if response.status_code != 200:
|
|
862
|
+
return response.status_code, []
|
|
863
|
+
events = (response.json() or {}).get("events")
|
|
864
|
+
return 200, [e for e in events if isinstance(e, dict)] if isinstance(events, list) else []
|
|
865
|
+
except Exception as e:
|
|
866
|
+
logger.warning(f"Reading activity of run {run_id} failed: {e}")
|
|
867
|
+
return 0, []
|
|
868
|
+
|
|
814
869
|
def close(self) -> None:
|
|
815
870
|
"""Close the client connection."""
|
|
816
871
|
try:
|
|
@@ -770,6 +770,7 @@ Session:
|
|
|
770
770
|
/queue remove <n> Drop one waiting prompt, by its number in /queue list
|
|
771
771
|
/queue clear Drop every prompt waiting to run
|
|
772
772
|
/stats [7d|30d|90d|1y] Show run statistics: runs, tokens, spend (default: 7d)
|
|
773
|
+
/agents Show this run's subagents (also ctrl+g)
|
|
773
774
|
/usage [run_id] Show token usage for a run and its subagents (default: current run)
|
|
774
775
|
|
|
775
776
|
Files:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Where the TUI's blocking calls run: one daemon thread each.
|
|
2
|
+
|
|
3
|
+
asyncio runs `asyncio.to_thread` and Textual's thread workers on the loop's default
|
|
4
|
+
executor, whose threads Python waits for on exit, up to five minutes. A request still
|
|
5
|
+
waiting on the network when the user quits would then keep the closed app alive. These
|
|
6
|
+
threads end with the app; the calls they make are bounded by their own timeouts.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DaemonThreadExecutor(ThreadPoolExecutor):
|
|
16
|
+
"""Runs each call on a thread of its own that does not hold the process open. A
|
|
17
|
+
`ThreadPoolExecutor` only so asyncio accepts it as the default; it keeps no pool,
|
|
18
|
+
whose threads Python would wait for."""
|
|
19
|
+
|
|
20
|
+
def submit(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Future:
|
|
21
|
+
future: Future = Future()
|
|
22
|
+
|
|
23
|
+
def run() -> None:
|
|
24
|
+
if not future.set_running_or_notify_cancel():
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
future.set_result(fn(*args, **kwargs))
|
|
28
|
+
except BaseException as exc: # noqa: BLE001 - handed to whoever awaits it
|
|
29
|
+
future.set_exception(exc)
|
|
30
|
+
|
|
31
|
+
threading.Thread(target=run, name="kiwi-io", daemon=True).start()
|
|
32
|
+
return future
|
|
33
|
+
|
|
34
|
+
def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
|
|
35
|
+
"""Nothing to wait for: the threads end with the process."""
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""A run's own turns as the TUI shows them, read off the backend's typed activity stream
|
|
2
|
+
(`{run_id}-activity`), laid out the way Agent Studio lays them out: the answer as it
|
|
3
|
+
happened (text, tool calls, notices and thinking in order), then one summary line.
|
|
4
|
+
|
|
5
|
+
The stream sends one JSON event per message: `turn.started/finished`, `step.started`,
|
|
6
|
+
`text.delta`, `reasoning.delta`, `tool.started/args.delta/ready/running/finished`,
|
|
7
|
+
`notice`, plus `subagent.*` and `question.*`, which other parts of the TUI handle. A
|
|
8
|
+
client that connects late reads the run's recent events back first
|
|
9
|
+
(`GET /v1/server_sent_events/activity/{run_id}`); that read-back and the live stream
|
|
10
|
+
overlap, so an event already seen (same `src` and `seq`) is dropped.
|
|
11
|
+
|
|
12
|
+
Memory stays bounded however long a run goes: each turn keeps its latest steps only,
|
|
13
|
+
the text of its latest thinking only, and the trace keeps its latest lines only.
|
|
14
|
+
|
|
15
|
+
Nothing here talks to the network or draws anything.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from collections import deque
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
#: Sent only by the process running a turn. While a turn is open, these from another
|
|
25
|
+
#: process are an earlier attempt's (the backend retried the turn while it still ran).
|
|
26
|
+
TURN_OWN = frozenset({
|
|
27
|
+
"turn.finished", "step.started", "text.delta", "reasoning.delta",
|
|
28
|
+
"tool.started", "tool.args.delta", "tool.ready", "tool.running", "tool.finished",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
#: The steps a turn keeps; older ones are counted, not kept.
|
|
32
|
+
TURN_ITEMS = 300
|
|
33
|
+
#: The tool calls of a turn whose arguments are kept, for their details; the latest only.
|
|
34
|
+
ARGS_KEPT = 50
|
|
35
|
+
ARGS_CHARS = 2000
|
|
36
|
+
NOTES_KEPT = 3
|
|
37
|
+
#: The thinking blocks of a turn whose text is kept, and how much of each.
|
|
38
|
+
THOUGHTS_KEPT = 20
|
|
39
|
+
THOUGHT_CHARS = 8000
|
|
40
|
+
#: The trace lines kept for the trace panel.
|
|
41
|
+
TRACE_LINES = 200
|
|
42
|
+
_TRACE_TEXT = 220
|
|
43
|
+
_MAX_EVENTS = 3000
|
|
44
|
+
_KEPT_TURNS = 8
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ToolLine:
|
|
49
|
+
kind = "tool"
|
|
50
|
+
call_id: str
|
|
51
|
+
name: str
|
|
52
|
+
step: int | None = None
|
|
53
|
+
label: str = ""
|
|
54
|
+
state: str = "forming" # forming / ready / running / ok / fail
|
|
55
|
+
parent: str | None = None
|
|
56
|
+
hosted: bool = False
|
|
57
|
+
duration_ms: int | None = None
|
|
58
|
+
error: str = ""
|
|
59
|
+
note: str = "" # its latest notice, shown while it runs
|
|
60
|
+
notes: list[str] = field(default_factory=list) # its latest few, for its details
|
|
61
|
+
args: str = "" # the server's preview of its arguments, as indented JSON
|
|
62
|
+
args_chars: int | None = None
|
|
63
|
+
args_truncated: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Block:
|
|
68
|
+
kind: str # text / reasoning / notice
|
|
69
|
+
step: int | None = None
|
|
70
|
+
text: str = ""
|
|
71
|
+
chars: int = 0 # all of it, however much of `text` is kept
|
|
72
|
+
id: int = 0 # numbers a turn's blocks, so one can be opened on screen
|
|
73
|
+
dropped: bool = False # its text was let go, to keep the latest turns' thinking
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class TurnView:
|
|
78
|
+
key: tuple[str, int]
|
|
79
|
+
src: str | None
|
|
80
|
+
name: str = ""
|
|
81
|
+
started_at: int = 0
|
|
82
|
+
finished: dict | None = None # {"ok", "error", "at"}
|
|
83
|
+
items: list = field(default_factory=list) # ToolLine / Block, in the order they happened
|
|
84
|
+
tools: dict[str, ToolLine] = field(default_factory=dict)
|
|
85
|
+
tool_total: int = 0
|
|
86
|
+
dropped: int = 0 # steps no longer kept
|
|
87
|
+
step: int | None = None # the model call it is on
|
|
88
|
+
text_chars: int = 0 # all the answer text it has streamed
|
|
89
|
+
#: How much of that text the screen has revealed so far: text arrives in bursts, and
|
|
90
|
+
#: the screen shows it at a steady pace. None (the default) shows it all.
|
|
91
|
+
revealed: int | None = None
|
|
92
|
+
_with_args: deque = field(default_factory=deque, repr=False)
|
|
93
|
+
_thoughts: deque = field(default_factory=deque, repr=False)
|
|
94
|
+
_blocks: int = 0
|
|
95
|
+
|
|
96
|
+
def new_block(self, kind: str, step: int | None) -> "Block":
|
|
97
|
+
self._blocks += 1
|
|
98
|
+
block = Block(kind=kind, step=step, id=self._blocks)
|
|
99
|
+
self._add(block)
|
|
100
|
+
if kind == "reasoning":
|
|
101
|
+
self._thoughts.append(block)
|
|
102
|
+
while len(self._thoughts) > THOUGHTS_KEPT:
|
|
103
|
+
old = self._thoughts.popleft()
|
|
104
|
+
old.text, old.dropped = "", True
|
|
105
|
+
return block
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def has_text(self) -> bool:
|
|
109
|
+
return any(isinstance(i, Block) and i.kind == "text" and i.text.strip() for i in self.items)
|
|
110
|
+
|
|
111
|
+
def doing(self) -> tuple[str, str]:
|
|
112
|
+
"""What the turn is doing now, for the status line: (kind, text), kind one of
|
|
113
|
+
tool / thinking / writing / working."""
|
|
114
|
+
running = [t for t in self.tools.values() if t.state == "running" and not t.parent]
|
|
115
|
+
if running:
|
|
116
|
+
tool = running[-1]
|
|
117
|
+
return "tool", tool.label or tool.name
|
|
118
|
+
last = self.items[-1] if self.items else None
|
|
119
|
+
if isinstance(last, Block) and last.kind == "reasoning":
|
|
120
|
+
return "thinking", "Thinking"
|
|
121
|
+
if isinstance(last, Block) and last.kind == "text":
|
|
122
|
+
return "writing", "Writing"
|
|
123
|
+
if isinstance(last, ToolLine) and last.state in ("forming", "ready"):
|
|
124
|
+
return "tool", f"Preparing {last.label or last.name}"
|
|
125
|
+
return "working", "Working"
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def steps_key(self) -> str:
|
|
129
|
+
"""What opens or closes this turn's steps on screen."""
|
|
130
|
+
return f"steps:{self.key[0]}:{self.key[1]}"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def has_steps(self) -> bool:
|
|
134
|
+
"""Whether there is more to the turn than its answer."""
|
|
135
|
+
return any(not (isinstance(i, Block) and i.kind == "text") for i in self.items) or self.dropped > 0
|
|
136
|
+
|
|
137
|
+
def summary(self) -> str:
|
|
138
|
+
""""Used 3 tools · 12.4s", as Agent Studio ends a finished turn."""
|
|
139
|
+
if self.finished is None:
|
|
140
|
+
return ""
|
|
141
|
+
took = _took(max(0, (self.finished.get("at") or 0) - (self.started_at or 0)))
|
|
142
|
+
n = self.tool_total
|
|
143
|
+
used = f"Used {n} tool{'s' if n != 1 else ''} · {took}" if n else took
|
|
144
|
+
if self.finished.get("ok"):
|
|
145
|
+
return used
|
|
146
|
+
return f"{used} · {self.finished.get('error') or 'failed'}"
|
|
147
|
+
|
|
148
|
+
def keep_args(self, tool: "ToolLine", args: object, chars: object, truncated: object) -> None:
|
|
149
|
+
"""Keep a call's argument preview; the oldest calls give theirs up."""
|
|
150
|
+
try:
|
|
151
|
+
text = json.dumps(args, indent=2, ensure_ascii=False, default=str) if args else ""
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
text = str(args)
|
|
154
|
+
tool.args = text if len(text) <= ARGS_CHARS else text[: ARGS_CHARS - 1] + "…"
|
|
155
|
+
tool.args_chars = chars if isinstance(chars, int) else None
|
|
156
|
+
tool.args_truncated = bool(truncated)
|
|
157
|
+
if tool.call_id not in self._with_args:
|
|
158
|
+
self._with_args.append(tool.call_id)
|
|
159
|
+
while len(self._with_args) > ARGS_KEPT:
|
|
160
|
+
old = self.tools.get(self._with_args.popleft())
|
|
161
|
+
if old is not None:
|
|
162
|
+
old.args = ""
|
|
163
|
+
|
|
164
|
+
def _add(self, item) -> None:
|
|
165
|
+
self.items.append(item)
|
|
166
|
+
if len(self.items) > TURN_ITEMS:
|
|
167
|
+
old = self.items.pop(0)
|
|
168
|
+
self.dropped += 1
|
|
169
|
+
if isinstance(old, ToolLine):
|
|
170
|
+
self.tools.pop(old.call_id, None)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class TraceLine:
|
|
175
|
+
seq: int
|
|
176
|
+
at: int
|
|
177
|
+
type: str
|
|
178
|
+
what: str
|
|
179
|
+
count: int = 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _took(ms: int) -> str:
|
|
183
|
+
secs = ms / 1000
|
|
184
|
+
if secs < 60:
|
|
185
|
+
return f"{secs:.1f}s"
|
|
186
|
+
if secs < 3600:
|
|
187
|
+
return f"{int(secs // 60)}m {int(secs % 60)}s"
|
|
188
|
+
return f"{int(secs // 3600)}h {int(secs % 3600 // 60)}m"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _event_key(event: dict) -> tuple[str, int]:
|
|
192
|
+
try:
|
|
193
|
+
return str(event.get("src") or ""), int(event.get("seq") or 0)
|
|
194
|
+
except (TypeError, ValueError):
|
|
195
|
+
return str(event.get("src") or ""), 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _order(event: dict) -> tuple:
|
|
199
|
+
src, seq = _event_key(event)
|
|
200
|
+
return (event.get("at") or 0, src, seq)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _one_line(text: object, limit: int = _TRACE_TEXT) -> str:
|
|
204
|
+
line = " ".join(str(text or "").split())
|
|
205
|
+
return line if len(line) <= limit else line[: limit - 1] + "…"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def describe(event: dict) -> str:
|
|
209
|
+
"""One line on what an event says, as Agent Studio's trace shows it."""
|
|
210
|
+
kind = str(event.get("type") or "")
|
|
211
|
+
if kind.endswith(".delta") or kind == "notice":
|
|
212
|
+
return _one_line(event.get("text"))
|
|
213
|
+
if kind.startswith("subagent."):
|
|
214
|
+
who = event.get("name") or f"…{str(event.get('child_run_id') or '')[-6:]}"
|
|
215
|
+
if kind == "subagent.progress":
|
|
216
|
+
what = event.get("label")
|
|
217
|
+
elif kind == "subagent.finished":
|
|
218
|
+
what = "ok" if event.get("ok") else (event.get("error") or "failed")
|
|
219
|
+
elif kind == "subagent.started":
|
|
220
|
+
what = event.get("goal")
|
|
221
|
+
else:
|
|
222
|
+
what = "sent a message"
|
|
223
|
+
return _one_line(f"{who} · {what or ''}")
|
|
224
|
+
if kind.startswith("tool."):
|
|
225
|
+
text = str(event.get("label") or event.get("name") or "")
|
|
226
|
+
if kind == "tool.finished":
|
|
227
|
+
ms = event.get("duration_ms")
|
|
228
|
+
text += f" · {'ok' if event.get('ok') else 'failed'} · {ms if ms is not None else '–'} ms"
|
|
229
|
+
return _one_line(text)
|
|
230
|
+
if kind.startswith("question."):
|
|
231
|
+
n = len(event.get("questions") or event.get("question_ids") or [])
|
|
232
|
+
return _one_line(f"{n} question{'s' if n != 1 else ''}" + (f" · {event['status']}" if event.get("status") else ""))
|
|
233
|
+
if kind == "step.started":
|
|
234
|
+
return f"step {event.get('step')}"
|
|
235
|
+
if kind == "turn.started":
|
|
236
|
+
return _one_line(event.get("name"))
|
|
237
|
+
if kind == "turn.finished":
|
|
238
|
+
return "ok" if event.get("ok") else _one_line(event.get("error") or "failed")
|
|
239
|
+
return ""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class RunActivityView:
|
|
243
|
+
"""The turns of one run, built from its activity events; which of them the streaming
|
|
244
|
+
row follows; and a short trace of the latest events."""
|
|
245
|
+
|
|
246
|
+
def __init__(self) -> None:
|
|
247
|
+
self._events: list[dict] = []
|
|
248
|
+
self._seen: set[tuple[str, int]] = set()
|
|
249
|
+
self._turns: list[TurnView] = []
|
|
250
|
+
self._open: TurnView | None = None
|
|
251
|
+
self._stale: set[tuple[str, int]] = set()
|
|
252
|
+
self._followed: tuple[str, int] | None = None
|
|
253
|
+
self._following = False
|
|
254
|
+
self.trace: deque[TraceLine] = deque(maxlen=TRACE_LINES)
|
|
255
|
+
self.trace_start: int | None = None
|
|
256
|
+
#: Counts every change, so a redraw can tell whether there is anything new.
|
|
257
|
+
self.version = 0
|
|
258
|
+
|
|
259
|
+
# ---- input ----
|
|
260
|
+
|
|
261
|
+
def take(self, event: object) -> bool:
|
|
262
|
+
"""Add one event; whether it was new."""
|
|
263
|
+
if not isinstance(event, dict) or not event.get("type"):
|
|
264
|
+
return False
|
|
265
|
+
key = _event_key(event)
|
|
266
|
+
if key[0]:
|
|
267
|
+
if key in self._seen:
|
|
268
|
+
return False
|
|
269
|
+
self._seen.add(key)
|
|
270
|
+
in_order = not self._events or _order(event) >= _order(self._events[-1])
|
|
271
|
+
self._events.append(event)
|
|
272
|
+
self._trace(event)
|
|
273
|
+
if len(self._events) > _MAX_EVENTS:
|
|
274
|
+
del self._events[: len(self._events) - _MAX_EVENTS]
|
|
275
|
+
self._seen = {_event_key(e) for e in self._events}
|
|
276
|
+
in_order = False
|
|
277
|
+
if in_order:
|
|
278
|
+
self._apply(event)
|
|
279
|
+
else:
|
|
280
|
+
self._rebuild()
|
|
281
|
+
self.version += 1
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
def take_all(self, events: Iterable[object]) -> bool:
|
|
285
|
+
"""Add a batch, such as a read-back; whether anything was new."""
|
|
286
|
+
fresh = []
|
|
287
|
+
for e in events:
|
|
288
|
+
if not isinstance(e, dict) or not e.get("type"):
|
|
289
|
+
continue
|
|
290
|
+
key = _event_key(e)
|
|
291
|
+
if key[0] and key in self._seen:
|
|
292
|
+
continue
|
|
293
|
+
if key[0]:
|
|
294
|
+
self._seen.add(key)
|
|
295
|
+
fresh.append(e)
|
|
296
|
+
if not fresh:
|
|
297
|
+
return False
|
|
298
|
+
self._events.extend(fresh)
|
|
299
|
+
self._events.sort(key=_order)
|
|
300
|
+
del self._events[: max(0, len(self._events) - _MAX_EVENTS)]
|
|
301
|
+
for e in sorted(fresh, key=_order):
|
|
302
|
+
self._trace(e)
|
|
303
|
+
self._rebuild()
|
|
304
|
+
self.version += 1
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
# ---- following ----
|
|
308
|
+
|
|
309
|
+
def start_following(self) -> None:
|
|
310
|
+
"""From now on the row follows a turn that has not already finished: what the
|
|
311
|
+
read-back holds of earlier turns is not this prompt's."""
|
|
312
|
+
self._stale = {t.key for t in self._turns if t.finished is not None}
|
|
313
|
+
self._followed = None
|
|
314
|
+
self._following = True
|
|
315
|
+
self.version += 1
|
|
316
|
+
|
|
317
|
+
def follow_next(self) -> None:
|
|
318
|
+
"""The run has moved on to a newer turn: follow the next one that has not finished
|
|
319
|
+
(one that has is already on screen from the stored run). A turn still running is
|
|
320
|
+
that newer turn, already being followed, and stays followed."""
|
|
321
|
+
self._stale |= {t.key for t in self._turns if t.finished is not None}
|
|
322
|
+
if self._followed in self._stale:
|
|
323
|
+
self._followed = None
|
|
324
|
+
self.version += 1
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def followed(self) -> TurnView | None:
|
|
328
|
+
if not self._following:
|
|
329
|
+
return None
|
|
330
|
+
last = self._turns[-1] if self._turns else None
|
|
331
|
+
if self._followed is not None:
|
|
332
|
+
for t in self._turns:
|
|
333
|
+
if t.key == self._followed and not self._abandoned(t, last):
|
|
334
|
+
return t
|
|
335
|
+
for t in self._turns:
|
|
336
|
+
if t.key not in self._stale and not self._abandoned(t, last):
|
|
337
|
+
self._followed = t.key
|
|
338
|
+
return t
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _abandoned(turn: TurnView, last: TurnView | None) -> bool:
|
|
343
|
+
"""A turn that never finished yet has a newer one after it: a run runs one turn
|
|
344
|
+
at a time, so it was given up (a retried or lost attempt)."""
|
|
345
|
+
return turn.finished is None and last is not None and turn is not last
|
|
346
|
+
|
|
347
|
+
def followed_finished(self) -> bool:
|
|
348
|
+
turn = self.followed
|
|
349
|
+
return turn is not None and turn.finished is not None
|
|
350
|
+
|
|
351
|
+
# ---- trace ----
|
|
352
|
+
|
|
353
|
+
def _trace(self, event: dict) -> None:
|
|
354
|
+
kind = str(event.get("type") or "")
|
|
355
|
+
at = int(event.get("at") or 0)
|
|
356
|
+
if self.trace_start is None:
|
|
357
|
+
self.trace_start = at
|
|
358
|
+
last = self.trace[-1] if self.trace else None
|
|
359
|
+
# Deltas arrive many to a sentence: one line counts them.
|
|
360
|
+
if kind.endswith(".delta") and last is not None and last.type == kind:
|
|
361
|
+
last.count += 1
|
|
362
|
+
last.what = _one_line(last.what + str(event.get("text") or ""))
|
|
363
|
+
return
|
|
364
|
+
self.trace.append(TraceLine(seq=_event_key(event)[1], at=at, type=kind, what=describe(event)))
|
|
365
|
+
|
|
366
|
+
# ---- reducer ----
|
|
367
|
+
|
|
368
|
+
def _rebuild(self) -> None:
|
|
369
|
+
# How far each turn's text was revealed is the screen's, not the events': keep it.
|
|
370
|
+
revealed = {t.key: t.revealed for t in self._turns}
|
|
371
|
+
self._turns = []
|
|
372
|
+
self._open = None
|
|
373
|
+
for e in self._events:
|
|
374
|
+
self._apply(e)
|
|
375
|
+
for t in self._turns:
|
|
376
|
+
if t.key in revealed:
|
|
377
|
+
t.revealed = revealed[t.key] if revealed[t.key] is None else min(revealed[t.key], t.text_chars)
|
|
378
|
+
|
|
379
|
+
def _turn_for(self, event: dict) -> TurnView:
|
|
380
|
+
if self._open is None:
|
|
381
|
+
src, seq = _event_key(event)
|
|
382
|
+
# Its `turn.started` is not in what was read back.
|
|
383
|
+
self._open = TurnView(key=(f"~{src}", seq), src=None, started_at=event.get("at") or 0)
|
|
384
|
+
self._add_turn(self._open)
|
|
385
|
+
return self._open
|
|
386
|
+
|
|
387
|
+
def _add_turn(self, turn: TurnView) -> None:
|
|
388
|
+
self._turns.append(turn)
|
|
389
|
+
# Only the latest few turns can be followed; the rest are on screen already.
|
|
390
|
+
if len(self._turns) > _KEPT_TURNS:
|
|
391
|
+
del self._turns[:-_KEPT_TURNS]
|
|
392
|
+
|
|
393
|
+
def _apply(self, event: dict) -> None:
|
|
394
|
+
kind = str(event.get("type") or "")
|
|
395
|
+
if kind.startswith("subagent.") or kind.startswith("question."):
|
|
396
|
+
return
|
|
397
|
+
if kind == "turn.started":
|
|
398
|
+
src, seq = _event_key(event)
|
|
399
|
+
self._open = TurnView(
|
|
400
|
+
key=(src, seq), src=src or None, name=str(event.get("name") or ""),
|
|
401
|
+
started_at=event.get("at") or 0,
|
|
402
|
+
)
|
|
403
|
+
self._add_turn(self._open)
|
|
404
|
+
return
|
|
405
|
+
if kind not in TURN_OWN and kind != "notice":
|
|
406
|
+
return
|
|
407
|
+
turn = self._turn_for(event)
|
|
408
|
+
if (
|
|
409
|
+
turn.src and kind in TURN_OWN and event.get("src") != turn.src
|
|
410
|
+
and not (kind == "turn.finished" and event.get("status"))
|
|
411
|
+
):
|
|
412
|
+
return
|
|
413
|
+
if kind == "turn.finished":
|
|
414
|
+
turn.finished = {"ok": bool(event.get("ok")), "error": str(event.get("error") or ""), "at": event.get("at") or 0}
|
|
415
|
+
self._open = None
|
|
416
|
+
return
|
|
417
|
+
if kind == "step.started":
|
|
418
|
+
if isinstance(event.get("step"), int):
|
|
419
|
+
turn.step = event["step"]
|
|
420
|
+
return
|
|
421
|
+
last = turn.items[-1] if turn.items else None
|
|
422
|
+
step = event.get("step")
|
|
423
|
+
if kind in ("text.delta", "reasoning.delta"):
|
|
424
|
+
block_kind = "text" if kind == "text.delta" else "reasoning"
|
|
425
|
+
text = str(event.get("text") or "")
|
|
426
|
+
if not (isinstance(last, Block) and last.kind == block_kind and last.step == step):
|
|
427
|
+
last = turn.new_block(block_kind, step)
|
|
428
|
+
last.chars += len(text)
|
|
429
|
+
if block_kind == "text":
|
|
430
|
+
last.text += text
|
|
431
|
+
turn.text_chars += len(text)
|
|
432
|
+
elif not last.dropped and len(last.text) < THOUGHT_CHARS:
|
|
433
|
+
last.text = (last.text + text)[:THOUGHT_CHARS]
|
|
434
|
+
return
|
|
435
|
+
call_id = str(event.get("call_id") or "")
|
|
436
|
+
if kind == "notice":
|
|
437
|
+
text = str(event.get("text") or "")
|
|
438
|
+
tool = turn.tools.get(call_id) if call_id else None
|
|
439
|
+
if tool is not None:
|
|
440
|
+
tool.note = text
|
|
441
|
+
tool.notes.append(text)
|
|
442
|
+
del tool.notes[:-NOTES_KEPT]
|
|
443
|
+
elif text:
|
|
444
|
+
turn.new_block("notice", None).text = text
|
|
445
|
+
return
|
|
446
|
+
if not call_id:
|
|
447
|
+
return
|
|
448
|
+
tool = turn.tools.get(call_id)
|
|
449
|
+
if tool is None:
|
|
450
|
+
tool = ToolLine(
|
|
451
|
+
call_id=call_id, name=str(event.get("name") or "tool"), step=step,
|
|
452
|
+
parent=event.get("parent_call_id"),
|
|
453
|
+
)
|
|
454
|
+
turn.tools[call_id] = tool
|
|
455
|
+
turn.tool_total += 1
|
|
456
|
+
turn._add(tool)
|
|
457
|
+
if event.get("name"):
|
|
458
|
+
tool.name = str(event["name"])
|
|
459
|
+
if event.get("label"):
|
|
460
|
+
tool.label = str(event["label"])
|
|
461
|
+
if event.get("hosted"):
|
|
462
|
+
tool.hosted = True
|
|
463
|
+
if kind == "tool.ready":
|
|
464
|
+
turn.keep_args(tool, event.get("args"), event.get("args_chars"), event.get("args_truncated"))
|
|
465
|
+
if tool.state == "forming":
|
|
466
|
+
tool.state = "ready"
|
|
467
|
+
elif kind == "tool.running":
|
|
468
|
+
tool.state = "running"
|
|
469
|
+
elif kind == "tool.finished":
|
|
470
|
+
tool.state = "ok" if event.get("ok") else "fail"
|
|
471
|
+
tool.error = str(event.get("error") or "")
|
|
472
|
+
tool.duration_ms = event.get("duration_ms")
|
|
473
|
+
tool.note = ""
|