codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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.
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -150
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +21 -7
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -274
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -1,22 +1,90 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
5
|
+
import logging
|
|
4
6
|
import os
|
|
5
7
|
import time
|
|
8
|
+
from contextlib import suppress
|
|
6
9
|
from dataclasses import dataclass
|
|
7
10
|
from pathlib import Path
|
|
8
|
-
from typing import
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
AsyncIterator,
|
|
14
|
+
Awaitable,
|
|
15
|
+
Callable,
|
|
16
|
+
MutableMapping,
|
|
17
|
+
Optional,
|
|
18
|
+
cast,
|
|
19
|
+
)
|
|
9
20
|
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from ...core.logging_utils import log_event
|
|
24
|
+
from ...core.utils import infer_home_from_workspace
|
|
10
25
|
from .events import SSEEvent
|
|
11
26
|
|
|
12
27
|
PermissionDecision = str
|
|
13
28
|
PermissionHandler = Callable[[str, dict[str, Any]], Awaitable[PermissionDecision]]
|
|
29
|
+
QuestionHandler = Callable[[str, dict[str, Any]], Awaitable[Optional[list[list[str]]]]]
|
|
14
30
|
PartHandler = Callable[[str, dict[str, Any], Optional[str]], Awaitable[None]]
|
|
15
31
|
|
|
16
32
|
PERMISSION_ALLOW = "allow"
|
|
17
33
|
PERMISSION_DENY = "deny"
|
|
18
34
|
PERMISSION_ASK = "ask"
|
|
19
35
|
|
|
36
|
+
OPENCODE_PERMISSION_ONCE = "once"
|
|
37
|
+
OPENCODE_PERMISSION_ALWAYS = "always"
|
|
38
|
+
OPENCODE_PERMISSION_REJECT = "reject"
|
|
39
|
+
|
|
40
|
+
_OPENCODE_STREAM_STALL_TIMEOUT_SECONDS = 60.0
|
|
41
|
+
_OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS = (0.5, 1.0, 2.0, 5.0, 10.0)
|
|
42
|
+
_OPENCODE_IDLE_STATUS_VALUES = {
|
|
43
|
+
"idle",
|
|
44
|
+
"done",
|
|
45
|
+
"completed",
|
|
46
|
+
"complete",
|
|
47
|
+
"finished",
|
|
48
|
+
"success",
|
|
49
|
+
}
|
|
50
|
+
_OPENCODE_USAGE_TOTAL_KEYS = ("totalTokens", "total_tokens", "total")
|
|
51
|
+
_OPENCODE_USAGE_INPUT_KEYS = (
|
|
52
|
+
"inputTokens",
|
|
53
|
+
"input_tokens",
|
|
54
|
+
"promptTokens",
|
|
55
|
+
"prompt_tokens",
|
|
56
|
+
)
|
|
57
|
+
_OPENCODE_USAGE_CACHED_KEYS = (
|
|
58
|
+
"cachedTokens",
|
|
59
|
+
"cached_tokens",
|
|
60
|
+
"cachedInputTokens",
|
|
61
|
+
"cached_input_tokens",
|
|
62
|
+
)
|
|
63
|
+
_OPENCODE_USAGE_OUTPUT_KEYS = (
|
|
64
|
+
"outputTokens",
|
|
65
|
+
"output_tokens",
|
|
66
|
+
"completionTokens",
|
|
67
|
+
"completion_tokens",
|
|
68
|
+
)
|
|
69
|
+
_OPENCODE_USAGE_REASONING_KEYS = (
|
|
70
|
+
"reasoningTokens",
|
|
71
|
+
"reasoning_tokens",
|
|
72
|
+
"reasoningOutputTokens",
|
|
73
|
+
"reasoning_output_tokens",
|
|
74
|
+
)
|
|
75
|
+
_OPENCODE_CONTEXT_WINDOW_KEYS = (
|
|
76
|
+
"modelContextWindow",
|
|
77
|
+
"contextWindow",
|
|
78
|
+
"context_window",
|
|
79
|
+
"contextWindowSize",
|
|
80
|
+
"context_window_size",
|
|
81
|
+
"contextLength",
|
|
82
|
+
"context_length",
|
|
83
|
+
"maxTokens",
|
|
84
|
+
"max_tokens",
|
|
85
|
+
)
|
|
86
|
+
_OPENCODE_MODEL_CONTEXT_KEYS = ("context",) + _OPENCODE_CONTEXT_WINDOW_KEYS
|
|
87
|
+
|
|
20
88
|
|
|
21
89
|
@dataclass(frozen=True)
|
|
22
90
|
class OpenCodeMessageResult:
|
|
@@ -60,17 +128,19 @@ def extract_session_id(
|
|
|
60
128
|
return value
|
|
61
129
|
properties = payload.get("properties")
|
|
62
130
|
if isinstance(properties, dict):
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return value
|
|
66
|
-
part = properties.get("part")
|
|
67
|
-
if isinstance(part, dict):
|
|
68
|
-
value = part.get("sessionID")
|
|
131
|
+
for key in ("sessionID", "sessionId", "session_id"):
|
|
132
|
+
value = properties.get(key)
|
|
69
133
|
if isinstance(value, str) and value:
|
|
70
134
|
return value
|
|
135
|
+
part = properties.get("part")
|
|
136
|
+
if isinstance(part, dict):
|
|
137
|
+
for key in ("sessionID", "sessionId", "session_id"):
|
|
138
|
+
value = part.get(key)
|
|
139
|
+
if isinstance(value, str) and value:
|
|
140
|
+
return value
|
|
71
141
|
session = payload.get("session")
|
|
72
142
|
if isinstance(session, dict):
|
|
73
|
-
return extract_session_id(session, allow_fallback_id=
|
|
143
|
+
return extract_session_id(session, allow_fallback_id=True)
|
|
74
144
|
return None
|
|
75
145
|
|
|
76
146
|
|
|
@@ -89,6 +159,32 @@ def extract_turn_id(session_id: str, payload: Any) -> str:
|
|
|
89
159
|
return build_turn_id(session_id)
|
|
90
160
|
|
|
91
161
|
|
|
162
|
+
def _extract_model_ids(payload: Any) -> tuple[Optional[str], Optional[str]]:
|
|
163
|
+
if not isinstance(payload, dict):
|
|
164
|
+
return None, None
|
|
165
|
+
for container in (payload, payload.get("properties"), payload.get("info")):
|
|
166
|
+
if not isinstance(container, dict):
|
|
167
|
+
continue
|
|
168
|
+
provider_id = (
|
|
169
|
+
container.get("providerID")
|
|
170
|
+
or container.get("providerId")
|
|
171
|
+
or container.get("provider_id")
|
|
172
|
+
)
|
|
173
|
+
model_id = (
|
|
174
|
+
container.get("modelID")
|
|
175
|
+
or container.get("modelId")
|
|
176
|
+
or container.get("model_id")
|
|
177
|
+
)
|
|
178
|
+
if (
|
|
179
|
+
isinstance(provider_id, str)
|
|
180
|
+
and provider_id.strip()
|
|
181
|
+
and isinstance(model_id, str)
|
|
182
|
+
and model_id.strip()
|
|
183
|
+
):
|
|
184
|
+
return provider_id, model_id
|
|
185
|
+
return None, None
|
|
186
|
+
|
|
187
|
+
|
|
92
188
|
def parse_message_response(payload: Any) -> OpenCodeMessageResult:
|
|
93
189
|
if not isinstance(payload, dict):
|
|
94
190
|
return OpenCodeMessageResult(text="")
|
|
@@ -140,6 +236,139 @@ def _extract_permission_request(payload: Any) -> tuple[Optional[str], dict[str,
|
|
|
140
236
|
return None, {}
|
|
141
237
|
|
|
142
238
|
|
|
239
|
+
def _normalize_question_policy(policy: Optional[str]) -> str:
|
|
240
|
+
if not policy:
|
|
241
|
+
return "ignore"
|
|
242
|
+
normalized = policy.strip().lower()
|
|
243
|
+
if normalized in ("auto_first_option", "auto_first", "first", "first_option"):
|
|
244
|
+
return "auto_first_option"
|
|
245
|
+
if normalized in ("auto_unanswered", "unanswered", "empty"):
|
|
246
|
+
return "auto_unanswered"
|
|
247
|
+
if normalized in ("reject", "deny", "cancel"):
|
|
248
|
+
return "reject"
|
|
249
|
+
if normalized in ("ignore", "none"):
|
|
250
|
+
return "ignore"
|
|
251
|
+
return "ignore"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _normalize_questions(raw: Any) -> list[dict[str, Any]]:
|
|
255
|
+
if not isinstance(raw, list):
|
|
256
|
+
return []
|
|
257
|
+
questions: list[dict[str, Any]] = []
|
|
258
|
+
for item in raw:
|
|
259
|
+
if isinstance(item, dict):
|
|
260
|
+
questions.append(item)
|
|
261
|
+
elif isinstance(item, str):
|
|
262
|
+
questions.append({"text": item})
|
|
263
|
+
return questions
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _extract_question_request(payload: Any) -> tuple[Optional[str], dict[str, Any]]:
|
|
267
|
+
if not isinstance(payload, dict):
|
|
268
|
+
return None, {}
|
|
269
|
+
properties = payload.get("properties")
|
|
270
|
+
base = properties if isinstance(properties, dict) else payload
|
|
271
|
+
if not isinstance(base, dict):
|
|
272
|
+
base = payload
|
|
273
|
+
request_id = None
|
|
274
|
+
for container in (base, payload):
|
|
275
|
+
if not isinstance(container, dict):
|
|
276
|
+
continue
|
|
277
|
+
for key in ("id", "requestID", "requestId"):
|
|
278
|
+
value = container.get(key)
|
|
279
|
+
if isinstance(value, str) and value:
|
|
280
|
+
request_id = value
|
|
281
|
+
break
|
|
282
|
+
if request_id:
|
|
283
|
+
break
|
|
284
|
+
questions = None
|
|
285
|
+
for container in (base, payload):
|
|
286
|
+
if not isinstance(container, dict):
|
|
287
|
+
continue
|
|
288
|
+
candidate = container.get("questions")
|
|
289
|
+
if isinstance(candidate, list):
|
|
290
|
+
questions = candidate
|
|
291
|
+
break
|
|
292
|
+
normalized = _normalize_questions(questions)
|
|
293
|
+
props = dict(base)
|
|
294
|
+
props["questions"] = normalized
|
|
295
|
+
return request_id, props
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _extract_question_option_label(option: Any) -> Optional[str]:
|
|
299
|
+
if isinstance(option, str):
|
|
300
|
+
return option.strip() or None
|
|
301
|
+
if isinstance(option, dict):
|
|
302
|
+
for key in ("label", "text", "value", "name", "id"):
|
|
303
|
+
value = option.get(key)
|
|
304
|
+
if isinstance(value, str) and value.strip():
|
|
305
|
+
return value.strip()
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _extract_question_options(question: dict[str, Any]) -> list[str]:
|
|
310
|
+
for key in ("options", "choices"):
|
|
311
|
+
raw = question.get(key)
|
|
312
|
+
if isinstance(raw, list):
|
|
313
|
+
options = []
|
|
314
|
+
for option in raw:
|
|
315
|
+
label = _extract_question_option_label(option)
|
|
316
|
+
if label:
|
|
317
|
+
options.append(label)
|
|
318
|
+
return options
|
|
319
|
+
return []
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _auto_answers_for_questions(
|
|
323
|
+
questions: list[dict[str, Any]], policy: str
|
|
324
|
+
) -> list[list[str]]:
|
|
325
|
+
if policy == "auto_unanswered":
|
|
326
|
+
return [[] for _ in questions]
|
|
327
|
+
answers: list[list[str]] = []
|
|
328
|
+
for question in questions:
|
|
329
|
+
options = _extract_question_options(question)
|
|
330
|
+
if options:
|
|
331
|
+
answers.append([options[0]])
|
|
332
|
+
else:
|
|
333
|
+
answers.append([])
|
|
334
|
+
return answers
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _normalize_question_answers(
|
|
338
|
+
answers: Any, *, question_count: int
|
|
339
|
+
) -> list[list[str]]:
|
|
340
|
+
if not isinstance(answers, list):
|
|
341
|
+
normalized: list[list[str]] = []
|
|
342
|
+
elif answers and all(isinstance(item, str) for item in answers):
|
|
343
|
+
normalized = [[item for item in answers if isinstance(item, str)]]
|
|
344
|
+
else:
|
|
345
|
+
normalized = []
|
|
346
|
+
for item in answers:
|
|
347
|
+
if isinstance(item, list):
|
|
348
|
+
normalized.append([entry for entry in item if isinstance(entry, str)])
|
|
349
|
+
elif isinstance(item, str):
|
|
350
|
+
normalized.append([item])
|
|
351
|
+
else:
|
|
352
|
+
normalized.append([])
|
|
353
|
+
if question_count <= 0:
|
|
354
|
+
return normalized
|
|
355
|
+
if len(normalized) < question_count:
|
|
356
|
+
normalized.extend([[] for _ in range(question_count - len(normalized))])
|
|
357
|
+
return normalized[:question_count]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _summarize_question_answers(answers: list[list[str]]) -> list[str]:
|
|
361
|
+
summary: list[str] = []
|
|
362
|
+
for answer in answers:
|
|
363
|
+
if not answer:
|
|
364
|
+
summary.append("")
|
|
365
|
+
elif len(answer) == 1:
|
|
366
|
+
summary.append(answer[0])
|
|
367
|
+
else:
|
|
368
|
+
summary.append(", ".join(answer))
|
|
369
|
+
return summary
|
|
370
|
+
|
|
371
|
+
|
|
143
372
|
def format_permission_prompt(payload: dict[str, Any]) -> str:
|
|
144
373
|
lines = ["Approval required"]
|
|
145
374
|
reason = payload.get("reason") or payload.get("message") or payload.get("detail")
|
|
@@ -178,10 +407,259 @@ def map_approval_policy_to_permission(
|
|
|
178
407
|
return default
|
|
179
408
|
|
|
180
409
|
|
|
410
|
+
def _normalize_permission_decision(decision: Any) -> str:
|
|
411
|
+
decision_norm = str(decision or "").strip().lower()
|
|
412
|
+
if decision_norm in (
|
|
413
|
+
"always",
|
|
414
|
+
"accept_session",
|
|
415
|
+
"accept-session",
|
|
416
|
+
"allow_session",
|
|
417
|
+
"allow-session",
|
|
418
|
+
"session",
|
|
419
|
+
"session_allow",
|
|
420
|
+
):
|
|
421
|
+
return OPENCODE_PERMISSION_ALWAYS
|
|
422
|
+
if decision_norm in (
|
|
423
|
+
"allow",
|
|
424
|
+
"approved",
|
|
425
|
+
"approve",
|
|
426
|
+
"accept",
|
|
427
|
+
"accepted",
|
|
428
|
+
"yes",
|
|
429
|
+
"y",
|
|
430
|
+
"ok",
|
|
431
|
+
"okay",
|
|
432
|
+
"true",
|
|
433
|
+
"1",
|
|
434
|
+
):
|
|
435
|
+
return OPENCODE_PERMISSION_ONCE
|
|
436
|
+
if decision_norm in (
|
|
437
|
+
"deny",
|
|
438
|
+
"reject",
|
|
439
|
+
"decline",
|
|
440
|
+
"declined",
|
|
441
|
+
"cancel",
|
|
442
|
+
"no",
|
|
443
|
+
"n",
|
|
444
|
+
"false",
|
|
445
|
+
"0",
|
|
446
|
+
):
|
|
447
|
+
return OPENCODE_PERMISSION_REJECT
|
|
448
|
+
return OPENCODE_PERMISSION_REJECT
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _permission_policy_reply(policy: str) -> str:
|
|
452
|
+
if policy == PERMISSION_ALLOW:
|
|
453
|
+
return OPENCODE_PERMISSION_ONCE
|
|
454
|
+
return OPENCODE_PERMISSION_REJECT
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _coerce_int(value: Any) -> Optional[int]:
|
|
458
|
+
if isinstance(value, bool):
|
|
459
|
+
return None
|
|
460
|
+
try:
|
|
461
|
+
return int(value)
|
|
462
|
+
except Exception:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _extract_usage_field(
|
|
467
|
+
payload: dict[str, Any], keys: tuple[str, ...]
|
|
468
|
+
) -> Optional[int]:
|
|
469
|
+
for key in keys:
|
|
470
|
+
if key in payload:
|
|
471
|
+
value = _coerce_int(payload.get(key))
|
|
472
|
+
if value is not None:
|
|
473
|
+
return value
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _flatten_opencode_tokens(tokens: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
478
|
+
usage: dict[str, Any] = {}
|
|
479
|
+
total_tokens = _coerce_int(tokens.get("total"))
|
|
480
|
+
if total_tokens is not None:
|
|
481
|
+
usage["totalTokens"] = total_tokens
|
|
482
|
+
input_tokens = _coerce_int(tokens.get("input"))
|
|
483
|
+
if input_tokens is not None:
|
|
484
|
+
usage["inputTokens"] = input_tokens
|
|
485
|
+
output_tokens = _coerce_int(tokens.get("output"))
|
|
486
|
+
if output_tokens is not None:
|
|
487
|
+
usage["outputTokens"] = output_tokens
|
|
488
|
+
reasoning_tokens = _coerce_int(tokens.get("reasoning"))
|
|
489
|
+
if reasoning_tokens is not None:
|
|
490
|
+
usage["reasoningTokens"] = reasoning_tokens
|
|
491
|
+
cache = tokens.get("cache")
|
|
492
|
+
if isinstance(cache, dict):
|
|
493
|
+
cached_read = _coerce_int(cache.get("read"))
|
|
494
|
+
if cached_read is not None:
|
|
495
|
+
usage["cachedInputTokens"] = cached_read
|
|
496
|
+
cached_write = _coerce_int(cache.get("write"))
|
|
497
|
+
if cached_write is not None:
|
|
498
|
+
usage["cacheWriteTokens"] = cached_write
|
|
499
|
+
if "totalTokens" not in usage:
|
|
500
|
+
components = [
|
|
501
|
+
usage.get("inputTokens"),
|
|
502
|
+
usage.get("outputTokens"),
|
|
503
|
+
usage.get("reasoningTokens"),
|
|
504
|
+
usage.get("cachedInputTokens"),
|
|
505
|
+
usage.get("cacheWriteTokens"),
|
|
506
|
+
]
|
|
507
|
+
numeric = [value for value in components if isinstance(value, int)]
|
|
508
|
+
if numeric:
|
|
509
|
+
usage["totalTokens"] = sum(numeric)
|
|
510
|
+
return usage or None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _extract_usage_payload(payload: Any) -> Optional[dict[str, Any]]:
|
|
514
|
+
if not isinstance(payload, dict):
|
|
515
|
+
return None
|
|
516
|
+
containers = [payload]
|
|
517
|
+
info = payload.get("info")
|
|
518
|
+
if isinstance(info, dict):
|
|
519
|
+
containers.append(info)
|
|
520
|
+
properties = payload.get("properties")
|
|
521
|
+
if isinstance(properties, dict):
|
|
522
|
+
containers.append(properties)
|
|
523
|
+
prop_info = properties.get("info")
|
|
524
|
+
if isinstance(prop_info, dict):
|
|
525
|
+
containers.append(prop_info)
|
|
526
|
+
response = payload.get("response")
|
|
527
|
+
if isinstance(response, dict):
|
|
528
|
+
containers.append(response)
|
|
529
|
+
for container in containers:
|
|
530
|
+
for key in (
|
|
531
|
+
"usage",
|
|
532
|
+
"token_usage",
|
|
533
|
+
"tokenUsage",
|
|
534
|
+
"usage_stats",
|
|
535
|
+
"usageStats",
|
|
536
|
+
"stats",
|
|
537
|
+
):
|
|
538
|
+
usage = container.get(key)
|
|
539
|
+
if isinstance(usage, dict):
|
|
540
|
+
return usage
|
|
541
|
+
tokens = container.get("tokens")
|
|
542
|
+
if isinstance(tokens, dict):
|
|
543
|
+
flattened = _flatten_opencode_tokens(tokens)
|
|
544
|
+
if flattened:
|
|
545
|
+
return flattened
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _extract_total_tokens(usage: dict[str, Any]) -> Optional[int]:
|
|
550
|
+
total = _extract_usage_field(usage, _OPENCODE_USAGE_TOTAL_KEYS)
|
|
551
|
+
if total is not None:
|
|
552
|
+
return total
|
|
553
|
+
input_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_INPUT_KEYS) or 0
|
|
554
|
+
cached_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_CACHED_KEYS) or 0
|
|
555
|
+
output_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_OUTPUT_KEYS) or 0
|
|
556
|
+
reasoning_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_REASONING_KEYS) or 0
|
|
557
|
+
if input_tokens or cached_tokens or output_tokens or reasoning_tokens:
|
|
558
|
+
return input_tokens + cached_tokens + output_tokens + reasoning_tokens
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _extract_usage_details(usage: dict[str, Any]) -> dict[str, int]:
|
|
563
|
+
details: dict[str, int] = {}
|
|
564
|
+
input_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_INPUT_KEYS)
|
|
565
|
+
if input_tokens is not None:
|
|
566
|
+
details["inputTokens"] = input_tokens
|
|
567
|
+
cached_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_CACHED_KEYS)
|
|
568
|
+
if cached_tokens is not None:
|
|
569
|
+
details["cachedInputTokens"] = cached_tokens
|
|
570
|
+
output_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_OUTPUT_KEYS)
|
|
571
|
+
if output_tokens is not None:
|
|
572
|
+
details["outputTokens"] = output_tokens
|
|
573
|
+
reasoning_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_REASONING_KEYS)
|
|
574
|
+
if reasoning_tokens is not None:
|
|
575
|
+
details["reasoningTokens"] = reasoning_tokens
|
|
576
|
+
return details
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _extract_context_window(
|
|
580
|
+
payload: Any, usage: Optional[dict[str, Any]]
|
|
581
|
+
) -> Optional[int]:
|
|
582
|
+
containers: list[dict[str, Any]] = []
|
|
583
|
+
if isinstance(payload, dict):
|
|
584
|
+
containers.append(payload)
|
|
585
|
+
info = payload.get("info")
|
|
586
|
+
if isinstance(info, dict):
|
|
587
|
+
containers.append(info)
|
|
588
|
+
properties = payload.get("properties")
|
|
589
|
+
if isinstance(properties, dict):
|
|
590
|
+
containers.append(properties)
|
|
591
|
+
prop_info = properties.get("info")
|
|
592
|
+
if isinstance(prop_info, dict):
|
|
593
|
+
containers.append(prop_info)
|
|
594
|
+
response = payload.get("response")
|
|
595
|
+
if isinstance(response, dict):
|
|
596
|
+
containers.append(response)
|
|
597
|
+
response_info = response.get("info")
|
|
598
|
+
if isinstance(response_info, dict):
|
|
599
|
+
containers.append(response_info)
|
|
600
|
+
response_props = response.get("properties")
|
|
601
|
+
if isinstance(response_props, dict):
|
|
602
|
+
containers.append(response_props)
|
|
603
|
+
response_prop_info = response_props.get("info")
|
|
604
|
+
if isinstance(response_prop_info, dict):
|
|
605
|
+
containers.append(response_prop_info)
|
|
606
|
+
for key in ("model", "modelInfo", "model_info", "modelConfig", "model_config"):
|
|
607
|
+
model = payload.get(key)
|
|
608
|
+
if isinstance(model, dict):
|
|
609
|
+
containers.append(model)
|
|
610
|
+
if isinstance(usage, dict):
|
|
611
|
+
containers.insert(0, usage)
|
|
612
|
+
for container in containers:
|
|
613
|
+
for key in _OPENCODE_CONTEXT_WINDOW_KEYS:
|
|
614
|
+
value = _coerce_int(container.get(key))
|
|
615
|
+
if value is not None and value > 0:
|
|
616
|
+
return value
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _extract_status_type(payload: Any) -> Optional[str]:
|
|
621
|
+
if not isinstance(payload, dict):
|
|
622
|
+
return None
|
|
623
|
+
for container in (
|
|
624
|
+
payload,
|
|
625
|
+
payload.get("status"),
|
|
626
|
+
payload.get("info"),
|
|
627
|
+
payload.get("properties"),
|
|
628
|
+
):
|
|
629
|
+
if not isinstance(container, dict):
|
|
630
|
+
continue
|
|
631
|
+
if container is payload:
|
|
632
|
+
status = container.get("status")
|
|
633
|
+
else:
|
|
634
|
+
status = container
|
|
635
|
+
if isinstance(status, dict):
|
|
636
|
+
value = status.get("type") or status.get("status")
|
|
637
|
+
else:
|
|
638
|
+
value = status
|
|
639
|
+
if isinstance(value, str) and value:
|
|
640
|
+
return value
|
|
641
|
+
properties = payload.get("properties")
|
|
642
|
+
if isinstance(properties, dict):
|
|
643
|
+
status = properties.get("status")
|
|
644
|
+
if isinstance(status, dict):
|
|
645
|
+
value = status.get("type") or status.get("status")
|
|
646
|
+
if isinstance(value, str) and value:
|
|
647
|
+
return value
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _status_is_idle(status_type: Optional[str]) -> bool:
|
|
652
|
+
if not status_type:
|
|
653
|
+
return False
|
|
654
|
+
return status_type.strip().lower() in _OPENCODE_IDLE_STATUS_VALUES
|
|
655
|
+
|
|
656
|
+
|
|
181
657
|
async def opencode_missing_env(
|
|
182
658
|
client: Any,
|
|
183
659
|
workspace_root: str,
|
|
184
660
|
model_payload: Optional[dict[str, str]],
|
|
661
|
+
*,
|
|
662
|
+
env: Optional[MutableMapping[str, str]] = None,
|
|
185
663
|
) -> list[str]:
|
|
186
664
|
if not model_payload:
|
|
187
665
|
return []
|
|
@@ -201,7 +679,7 @@ async def opencode_missing_env(
|
|
|
201
679
|
providers = [entry for entry in payload if isinstance(entry, dict)]
|
|
202
680
|
for provider in providers:
|
|
203
681
|
pid = provider.get("id") or provider.get("providerID")
|
|
204
|
-
if pid != provider_id:
|
|
682
|
+
if not pid or pid != provider_id:
|
|
205
683
|
continue
|
|
206
684
|
if _provider_has_auth(pid, workspace_root):
|
|
207
685
|
return []
|
|
@@ -211,12 +689,20 @@ async def opencode_missing_env(
|
|
|
211
689
|
missing = [
|
|
212
690
|
key
|
|
213
691
|
for key in env_keys
|
|
214
|
-
if isinstance(key, str) and key and not
|
|
692
|
+
if isinstance(key, str) and key and not _get_env_value(key, env)
|
|
215
693
|
]
|
|
216
694
|
return missing
|
|
217
695
|
return []
|
|
218
696
|
|
|
219
697
|
|
|
698
|
+
def _get_env_value(
|
|
699
|
+
key: str, env: Optional[MutableMapping[str, str]] = None
|
|
700
|
+
) -> Optional[str]:
|
|
701
|
+
if env is not None:
|
|
702
|
+
return env.get(key)
|
|
703
|
+
return os.getenv(key)
|
|
704
|
+
|
|
705
|
+
|
|
220
706
|
def _provider_has_auth(provider_id: str, workspace_root: str) -> bool:
|
|
221
707
|
auth_path = _find_opencode_auth_path(workspace_root)
|
|
222
708
|
if auth_path is None or not auth_path.exists():
|
|
@@ -236,7 +722,7 @@ def _find_opencode_auth_path(workspace_root: str) -> Optional[Path]:
|
|
|
236
722
|
if not data_home:
|
|
237
723
|
home = os.getenv("HOME")
|
|
238
724
|
if not home:
|
|
239
|
-
inferred =
|
|
725
|
+
inferred = infer_home_from_workspace(workspace_root)
|
|
240
726
|
if inferred is None:
|
|
241
727
|
return None
|
|
242
728
|
data_home = str(inferred / ".local" / "share")
|
|
@@ -245,40 +731,25 @@ def _find_opencode_auth_path(workspace_root: str) -> Optional[Path]:
|
|
|
245
731
|
return Path(data_home) / "opencode" / "auth.json"
|
|
246
732
|
|
|
247
733
|
|
|
248
|
-
def _infer_home_from_workspace(workspace_root: str) -> Optional[Path]:
|
|
249
|
-
resolved = Path(workspace_root).resolve()
|
|
250
|
-
parts = resolved.parts
|
|
251
|
-
if (
|
|
252
|
-
len(parts) >= 6
|
|
253
|
-
and parts[0] == os.path.sep
|
|
254
|
-
and parts[1] == "System"
|
|
255
|
-
and parts[2] == "Volumes"
|
|
256
|
-
and parts[3] == "Data"
|
|
257
|
-
and parts[4] == "Users"
|
|
258
|
-
):
|
|
259
|
-
return Path(parts[0]) / parts[1] / parts[2] / parts[3] / parts[4] / parts[5]
|
|
260
|
-
if (
|
|
261
|
-
len(parts) >= 3
|
|
262
|
-
and parts[0] == os.path.sep
|
|
263
|
-
and parts[1]
|
|
264
|
-
in (
|
|
265
|
-
"Users",
|
|
266
|
-
"home",
|
|
267
|
-
)
|
|
268
|
-
):
|
|
269
|
-
return Path(parts[0]) / parts[1] / parts[2]
|
|
270
|
-
return None
|
|
271
|
-
|
|
272
|
-
|
|
273
734
|
async def collect_opencode_output_from_events(
|
|
274
|
-
events: AsyncIterator[SSEEvent],
|
|
735
|
+
events: Optional[AsyncIterator[SSEEvent]] = None,
|
|
275
736
|
*,
|
|
276
737
|
session_id: str,
|
|
738
|
+
model_payload: Optional[dict[str, str]] = None,
|
|
739
|
+
progress_session_ids: Optional[set[str]] = None,
|
|
277
740
|
permission_policy: str = PERMISSION_ALLOW,
|
|
278
741
|
permission_handler: Optional[PermissionHandler] = None,
|
|
742
|
+
question_policy: str = "ignore",
|
|
743
|
+
question_handler: Optional[QuestionHandler] = None,
|
|
279
744
|
should_stop: Optional[Callable[[], bool]] = None,
|
|
280
745
|
respond_permission: Optional[Callable[[str, str], Awaitable[None]]] = None,
|
|
746
|
+
reply_question: Optional[Callable[[str, list[list[str]]], Awaitable[None]]] = None,
|
|
747
|
+
reject_question: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
281
748
|
part_handler: Optional[PartHandler] = None,
|
|
749
|
+
event_stream_factory: Optional[Callable[[], AsyncIterator[SSEEvent]]] = None,
|
|
750
|
+
session_fetcher: Optional[Callable[[], Awaitable[Any]]] = None,
|
|
751
|
+
provider_fetcher: Optional[Callable[[], Awaitable[Any]]] = None,
|
|
752
|
+
stall_timeout_seconds: Optional[float] = _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS,
|
|
282
753
|
) -> OpenCodeTurnOutput:
|
|
283
754
|
text_parts: list[str] = []
|
|
284
755
|
part_lengths: dict[str, int] = {}
|
|
@@ -288,6 +759,20 @@ async def collect_opencode_output_from_events(
|
|
|
288
759
|
message_roles_seen = False
|
|
289
760
|
last_role_seen: Optional[str] = None
|
|
290
761
|
pending_text: dict[str, list[str]] = {}
|
|
762
|
+
fallback_message: Optional[tuple[Optional[str], Optional[str], str]] = None
|
|
763
|
+
last_usage_total: Optional[int] = None
|
|
764
|
+
last_context_window: Optional[int] = None
|
|
765
|
+
part_types: dict[str, str] = {}
|
|
766
|
+
seen_question_request_ids: set[tuple[Optional[str], str]] = set()
|
|
767
|
+
logged_permission_errors: set[str] = set()
|
|
768
|
+
normalized_question_policy = _normalize_question_policy(question_policy)
|
|
769
|
+
logger = logging.getLogger(__name__)
|
|
770
|
+
providers_cache: Optional[list[dict[str, Any]]] = None
|
|
771
|
+
context_window_cache: dict[str, Optional[int]] = {}
|
|
772
|
+
session_model_ids: Optional[tuple[Optional[str], Optional[str]]] = None
|
|
773
|
+
default_model_ids = (
|
|
774
|
+
_extract_model_ids(model_payload) if isinstance(model_payload, dict) else None
|
|
775
|
+
)
|
|
291
776
|
|
|
292
777
|
def _message_id_from_info(info: Any) -> Optional[str]:
|
|
293
778
|
if not isinstance(info, dict):
|
|
@@ -361,106 +846,647 @@ async def collect_opencode_output_from_events(
|
|
|
361
846
|
text_parts.extend(pending)
|
|
362
847
|
pending_text.clear()
|
|
363
848
|
|
|
364
|
-
async
|
|
365
|
-
|
|
849
|
+
async def _resolve_session_model_ids() -> tuple[Optional[str], Optional[str]]:
|
|
850
|
+
nonlocal session_model_ids
|
|
851
|
+
if session_model_ids is not None:
|
|
852
|
+
return session_model_ids
|
|
853
|
+
resolved_ids: Optional[tuple[Optional[str], Optional[str]]] = None
|
|
854
|
+
if session_fetcher is not None:
|
|
855
|
+
try:
|
|
856
|
+
payload = await session_fetcher()
|
|
857
|
+
resolved_ids = _extract_model_ids(payload)
|
|
858
|
+
except Exception:
|
|
859
|
+
resolved_ids = None
|
|
860
|
+
# If we failed to resolve model ids from the session (including the empty
|
|
861
|
+
# tuple case), fall back to the caller-provided model payload so we can
|
|
862
|
+
# still backfill usage metadata.
|
|
863
|
+
if not resolved_ids or all(value is None for value in resolved_ids):
|
|
864
|
+
resolved_ids = default_model_ids
|
|
865
|
+
session_model_ids = resolved_ids or (None, None)
|
|
866
|
+
return session_model_ids
|
|
867
|
+
|
|
868
|
+
async def _resolve_context_window_from_providers(
|
|
869
|
+
provider_id: Optional[str], model_id: Optional[str]
|
|
870
|
+
) -> Optional[int]:
|
|
871
|
+
nonlocal providers_cache
|
|
872
|
+
if not provider_id or not model_id:
|
|
873
|
+
return None
|
|
874
|
+
cache_key = f"{provider_id}/{model_id}"
|
|
875
|
+
if cache_key in context_window_cache:
|
|
876
|
+
return context_window_cache[cache_key]
|
|
877
|
+
if provider_fetcher is None:
|
|
878
|
+
context_window_cache[cache_key] = None
|
|
879
|
+
return None
|
|
880
|
+
if providers_cache is None:
|
|
881
|
+
try:
|
|
882
|
+
payload = await provider_fetcher()
|
|
883
|
+
except Exception:
|
|
884
|
+
context_window_cache[cache_key] = None
|
|
885
|
+
return None
|
|
886
|
+
providers: list[dict[str, Any]] = []
|
|
887
|
+
if isinstance(payload, dict):
|
|
888
|
+
raw_providers = payload.get("providers")
|
|
889
|
+
if isinstance(raw_providers, list):
|
|
890
|
+
providers = [
|
|
891
|
+
entry for entry in raw_providers if isinstance(entry, dict)
|
|
892
|
+
]
|
|
893
|
+
elif isinstance(payload, list):
|
|
894
|
+
providers = [entry for entry in payload if isinstance(entry, dict)]
|
|
895
|
+
providers_cache = providers
|
|
896
|
+
context_window = None
|
|
897
|
+
for provider in providers_cache or []:
|
|
898
|
+
pid = provider.get("id") or provider.get("providerID")
|
|
899
|
+
if pid != provider_id:
|
|
900
|
+
continue
|
|
901
|
+
models = provider.get("models")
|
|
902
|
+
model_entry = None
|
|
903
|
+
if isinstance(models, dict):
|
|
904
|
+
candidate = models.get(model_id)
|
|
905
|
+
if isinstance(candidate, dict):
|
|
906
|
+
model_entry = candidate
|
|
907
|
+
elif isinstance(models, list):
|
|
908
|
+
for entry in models:
|
|
909
|
+
if not isinstance(entry, dict):
|
|
910
|
+
continue
|
|
911
|
+
entry_id = entry.get("id") or entry.get("modelID")
|
|
912
|
+
if entry_id == model_id:
|
|
913
|
+
model_entry = entry
|
|
914
|
+
break
|
|
915
|
+
if isinstance(model_entry, dict):
|
|
916
|
+
limit = model_entry.get("limit") or model_entry.get("limits")
|
|
917
|
+
if isinstance(limit, dict):
|
|
918
|
+
for key in _OPENCODE_MODEL_CONTEXT_KEYS:
|
|
919
|
+
value = _coerce_int(limit.get(key))
|
|
920
|
+
if value is not None and value > 0:
|
|
921
|
+
context_window = value
|
|
922
|
+
break
|
|
923
|
+
if context_window is None:
|
|
924
|
+
for key in _OPENCODE_MODEL_CONTEXT_KEYS:
|
|
925
|
+
value = _coerce_int(model_entry.get(key))
|
|
926
|
+
if value is not None and value > 0:
|
|
927
|
+
context_window = value
|
|
928
|
+
break
|
|
929
|
+
if context_window is None:
|
|
930
|
+
limit = provider.get("limit") or provider.get("limits")
|
|
931
|
+
if isinstance(limit, dict):
|
|
932
|
+
for key in _OPENCODE_MODEL_CONTEXT_KEYS:
|
|
933
|
+
value = _coerce_int(limit.get(key))
|
|
934
|
+
if value is not None and value > 0:
|
|
935
|
+
context_window = value
|
|
936
|
+
break
|
|
366
937
|
break
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
938
|
+
context_window_cache[cache_key] = context_window
|
|
939
|
+
return context_window
|
|
940
|
+
|
|
941
|
+
stream_factory = event_stream_factory
|
|
942
|
+
if events is None and stream_factory is None:
|
|
943
|
+
raise ValueError("events or event_stream_factory must be provided")
|
|
944
|
+
|
|
945
|
+
def _new_stream() -> AsyncIterator[SSEEvent]:
|
|
946
|
+
if stream_factory is not None:
|
|
947
|
+
return stream_factory()
|
|
948
|
+
if events is None:
|
|
949
|
+
raise ValueError("events or event_stream_factory must be provided")
|
|
950
|
+
return events
|
|
951
|
+
|
|
952
|
+
async def _close_stream(iterator: AsyncIterator[SSEEvent]) -> None:
|
|
953
|
+
aclose = getattr(iterator, "aclose", None)
|
|
954
|
+
if aclose is None:
|
|
955
|
+
return
|
|
956
|
+
with suppress(Exception):
|
|
957
|
+
await aclose()
|
|
958
|
+
|
|
959
|
+
stream_iter = _new_stream().__aiter__()
|
|
960
|
+
last_relevant_event_at = time.monotonic()
|
|
961
|
+
last_primary_completion_at: Optional[float] = None
|
|
962
|
+
reconnect_attempts = 0
|
|
963
|
+
can_reconnect = (
|
|
964
|
+
event_stream_factory is not None and stall_timeout_seconds is not None
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
try:
|
|
968
|
+
while True:
|
|
969
|
+
if should_stop is not None and should_stop():
|
|
970
|
+
break
|
|
971
|
+
try:
|
|
972
|
+
if can_reconnect and stall_timeout_seconds is not None:
|
|
973
|
+
event = await asyncio.wait_for(
|
|
974
|
+
stream_iter.__anext__(), timeout=stall_timeout_seconds
|
|
975
|
+
)
|
|
976
|
+
else:
|
|
977
|
+
event = await stream_iter.__anext__()
|
|
978
|
+
except StopAsyncIteration:
|
|
979
|
+
break
|
|
980
|
+
except asyncio.TimeoutError:
|
|
981
|
+
now = time.monotonic()
|
|
982
|
+
status_type = None
|
|
983
|
+
if session_fetcher is not None:
|
|
984
|
+
try:
|
|
985
|
+
payload = await session_fetcher()
|
|
986
|
+
status_type = _extract_status_type(payload)
|
|
987
|
+
except Exception as exc:
|
|
988
|
+
log_event(
|
|
989
|
+
logger,
|
|
990
|
+
logging.WARNING,
|
|
991
|
+
"opencode.session.poll_failed",
|
|
992
|
+
session_id=session_id,
|
|
993
|
+
exc=exc,
|
|
994
|
+
)
|
|
995
|
+
idle_seconds = now - last_relevant_event_at
|
|
996
|
+
if _status_is_idle(status_type):
|
|
997
|
+
log_event(
|
|
998
|
+
logger,
|
|
999
|
+
logging.INFO,
|
|
1000
|
+
"opencode.stream.stalled.session_idle",
|
|
1001
|
+
session_id=session_id,
|
|
1002
|
+
status_type=status_type,
|
|
1003
|
+
idle_seconds=idle_seconds,
|
|
1004
|
+
)
|
|
1005
|
+
if not text_parts and pending_text:
|
|
1006
|
+
_flush_all_pending_text()
|
|
1007
|
+
break
|
|
1008
|
+
if last_primary_completion_at is not None:
|
|
1009
|
+
log_event(
|
|
1010
|
+
logger,
|
|
1011
|
+
logging.INFO,
|
|
1012
|
+
"opencode.stream.stalled.after_completion",
|
|
1013
|
+
session_id=session_id,
|
|
1014
|
+
status_type=status_type,
|
|
1015
|
+
idle_seconds=idle_seconds,
|
|
1016
|
+
)
|
|
1017
|
+
if not can_reconnect:
|
|
1018
|
+
break
|
|
1019
|
+
backoff_index = min(
|
|
1020
|
+
reconnect_attempts,
|
|
1021
|
+
len(_OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS) - 1,
|
|
1022
|
+
)
|
|
1023
|
+
backoff = _OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS[backoff_index]
|
|
1024
|
+
reconnect_attempts += 1
|
|
1025
|
+
log_event(
|
|
1026
|
+
logger,
|
|
1027
|
+
logging.WARNING,
|
|
1028
|
+
"opencode.stream.stalled.reconnecting",
|
|
1029
|
+
session_id=session_id,
|
|
1030
|
+
idle_seconds=idle_seconds,
|
|
1031
|
+
backoff_seconds=backoff,
|
|
1032
|
+
status_type=status_type,
|
|
1033
|
+
attempts=reconnect_attempts,
|
|
1034
|
+
)
|
|
1035
|
+
await _close_stream(stream_iter)
|
|
1036
|
+
await asyncio.sleep(backoff)
|
|
1037
|
+
stream_iter = _new_stream().__aiter__()
|
|
1038
|
+
last_relevant_event_at = now
|
|
1039
|
+
continue
|
|
1040
|
+
now = time.monotonic()
|
|
1041
|
+
raw = event.data or ""
|
|
1042
|
+
try:
|
|
1043
|
+
payload = json.loads(raw) if raw else {}
|
|
1044
|
+
except json.JSONDecodeError:
|
|
1045
|
+
payload = {}
|
|
1046
|
+
event_session_id = extract_session_id(payload)
|
|
1047
|
+
is_relevant = False
|
|
1048
|
+
if event_session_id:
|
|
1049
|
+
if progress_session_ids is None:
|
|
1050
|
+
is_relevant = event_session_id == session_id
|
|
1051
|
+
else:
|
|
1052
|
+
is_relevant = event_session_id in progress_session_ids
|
|
1053
|
+
if not is_relevant:
|
|
1054
|
+
if (
|
|
1055
|
+
stall_timeout_seconds is not None
|
|
1056
|
+
and now - last_relevant_event_at > stall_timeout_seconds
|
|
384
1057
|
):
|
|
1058
|
+
idle_seconds = now - last_relevant_event_at
|
|
1059
|
+
last_relevant_event_at = now
|
|
1060
|
+
status_type = None
|
|
1061
|
+
if session_fetcher is not None:
|
|
1062
|
+
try:
|
|
1063
|
+
payload = await session_fetcher()
|
|
1064
|
+
status_type = _extract_status_type(payload)
|
|
1065
|
+
except Exception as exc:
|
|
1066
|
+
log_event(
|
|
1067
|
+
logger,
|
|
1068
|
+
logging.WARNING,
|
|
1069
|
+
"opencode.session.poll_failed",
|
|
1070
|
+
session_id=session_id,
|
|
1071
|
+
exc=exc,
|
|
1072
|
+
)
|
|
1073
|
+
if _status_is_idle(status_type):
|
|
1074
|
+
log_event(
|
|
1075
|
+
logger,
|
|
1076
|
+
logging.INFO,
|
|
1077
|
+
"opencode.stream.stalled.session_idle",
|
|
1078
|
+
session_id=session_id,
|
|
1079
|
+
status_type=status_type,
|
|
1080
|
+
idle_seconds=idle_seconds,
|
|
1081
|
+
)
|
|
1082
|
+
if not text_parts and pending_text:
|
|
1083
|
+
_flush_all_pending_text()
|
|
1084
|
+
break
|
|
1085
|
+
if last_primary_completion_at is not None:
|
|
1086
|
+
log_event(
|
|
1087
|
+
logger,
|
|
1088
|
+
logging.INFO,
|
|
1089
|
+
"opencode.stream.stalled.after_completion",
|
|
1090
|
+
session_id=session_id,
|
|
1091
|
+
status_type=status_type,
|
|
1092
|
+
idle_seconds=idle_seconds,
|
|
1093
|
+
)
|
|
1094
|
+
if not can_reconnect:
|
|
1095
|
+
break
|
|
1096
|
+
backoff_index = min(
|
|
1097
|
+
reconnect_attempts,
|
|
1098
|
+
len(_OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS) - 1,
|
|
1099
|
+
)
|
|
1100
|
+
backoff = _OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS[backoff_index]
|
|
1101
|
+
reconnect_attempts += 1
|
|
1102
|
+
log_event(
|
|
1103
|
+
logger,
|
|
1104
|
+
logging.WARNING,
|
|
1105
|
+
"opencode.stream.stalled.reconnecting",
|
|
1106
|
+
session_id=session_id,
|
|
1107
|
+
idle_seconds=idle_seconds,
|
|
1108
|
+
backoff_seconds=backoff,
|
|
1109
|
+
status_type=status_type,
|
|
1110
|
+
attempts=reconnect_attempts,
|
|
1111
|
+
)
|
|
1112
|
+
await _close_stream(stream_iter)
|
|
1113
|
+
await asyncio.sleep(backoff)
|
|
1114
|
+
stream_iter = _new_stream().__aiter__()
|
|
1115
|
+
continue
|
|
1116
|
+
last_relevant_event_at = now
|
|
1117
|
+
reconnect_attempts = 0
|
|
1118
|
+
is_primary_session = event_session_id == session_id
|
|
1119
|
+
if event.event == "question.asked":
|
|
1120
|
+
request_id, props = _extract_question_request(payload)
|
|
1121
|
+
questions = props.get("questions") if isinstance(props, dict) else []
|
|
1122
|
+
question_count = len(questions) if isinstance(questions, list) else 0
|
|
1123
|
+
log_event(
|
|
1124
|
+
logger,
|
|
1125
|
+
logging.INFO,
|
|
1126
|
+
"opencode.question.asked",
|
|
1127
|
+
request_id=request_id,
|
|
1128
|
+
question_count=question_count,
|
|
1129
|
+
session_id=event_session_id,
|
|
1130
|
+
)
|
|
1131
|
+
if not request_id:
|
|
1132
|
+
continue
|
|
1133
|
+
dedupe_key = (event_session_id, request_id)
|
|
1134
|
+
if dedupe_key in seen_question_request_ids:
|
|
1135
|
+
continue
|
|
1136
|
+
seen_question_request_ids.add(dedupe_key)
|
|
1137
|
+
if question_handler is not None:
|
|
385
1138
|
try:
|
|
386
|
-
|
|
387
|
-
except Exception:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
1139
|
+
answers = await question_handler(request_id, props)
|
|
1140
|
+
except Exception as exc:
|
|
1141
|
+
log_event(
|
|
1142
|
+
logger,
|
|
1143
|
+
logging.WARNING,
|
|
1144
|
+
"opencode.question.auto_reply_failed",
|
|
1145
|
+
request_id=request_id,
|
|
1146
|
+
session_id=event_session_id,
|
|
1147
|
+
exc=exc,
|
|
1148
|
+
)
|
|
1149
|
+
if reject_question is not None:
|
|
1150
|
+
try:
|
|
1151
|
+
await reject_question(request_id)
|
|
1152
|
+
except Exception:
|
|
1153
|
+
pass
|
|
1154
|
+
continue
|
|
1155
|
+
if answers is None:
|
|
1156
|
+
if reject_question is not None:
|
|
1157
|
+
try:
|
|
1158
|
+
await reject_question(request_id)
|
|
1159
|
+
except Exception:
|
|
1160
|
+
pass
|
|
1161
|
+
continue
|
|
1162
|
+
normalized_answers = _normalize_question_answers(
|
|
1163
|
+
answers, question_count=question_count
|
|
1164
|
+
)
|
|
1165
|
+
if reply_question is not None:
|
|
1166
|
+
try:
|
|
1167
|
+
await reply_question(request_id, normalized_answers)
|
|
1168
|
+
log_event(
|
|
1169
|
+
logger,
|
|
1170
|
+
logging.INFO,
|
|
1171
|
+
"opencode.question.replied",
|
|
1172
|
+
request_id=request_id,
|
|
1173
|
+
question_count=question_count,
|
|
1174
|
+
session_id=event_session_id,
|
|
1175
|
+
mode="handler",
|
|
1176
|
+
)
|
|
1177
|
+
except Exception as exc:
|
|
1178
|
+
log_event(
|
|
1179
|
+
logger,
|
|
1180
|
+
logging.WARNING,
|
|
1181
|
+
"opencode.question.auto_reply_failed",
|
|
1182
|
+
request_id=request_id,
|
|
1183
|
+
session_id=event_session_id,
|
|
1184
|
+
exc=exc,
|
|
1185
|
+
)
|
|
1186
|
+
continue
|
|
1187
|
+
if normalized_question_policy == "ignore":
|
|
1188
|
+
continue
|
|
1189
|
+
if normalized_question_policy == "reject":
|
|
1190
|
+
if reject_question is not None:
|
|
1191
|
+
try:
|
|
1192
|
+
await reject_question(request_id)
|
|
1193
|
+
except Exception as exc:
|
|
1194
|
+
log_event(
|
|
1195
|
+
logger,
|
|
1196
|
+
logging.WARNING,
|
|
1197
|
+
"opencode.question.auto_reply_failed",
|
|
1198
|
+
request_id=request_id,
|
|
1199
|
+
session_id=event_session_id,
|
|
1200
|
+
exc=exc,
|
|
1201
|
+
)
|
|
1202
|
+
continue
|
|
1203
|
+
auto_answers = _auto_answers_for_questions(
|
|
1204
|
+
questions if isinstance(questions, list) else [],
|
|
1205
|
+
normalized_question_policy,
|
|
1206
|
+
)
|
|
1207
|
+
normalized_answers = _normalize_question_answers(
|
|
1208
|
+
auto_answers, question_count=question_count
|
|
1209
|
+
)
|
|
1210
|
+
if reply_question is not None:
|
|
1211
|
+
try:
|
|
1212
|
+
await reply_question(request_id, normalized_answers)
|
|
1213
|
+
log_event(
|
|
1214
|
+
logger,
|
|
1215
|
+
logging.INFO,
|
|
1216
|
+
"opencode.question.auto_replied",
|
|
1217
|
+
request_id=request_id,
|
|
1218
|
+
question_count=question_count,
|
|
1219
|
+
session_id=event_session_id,
|
|
1220
|
+
policy=normalized_question_policy,
|
|
1221
|
+
answers=_summarize_question_answers(normalized_answers),
|
|
1222
|
+
)
|
|
1223
|
+
except Exception as exc:
|
|
1224
|
+
log_event(
|
|
1225
|
+
logger,
|
|
1226
|
+
logging.WARNING,
|
|
1227
|
+
"opencode.question.auto_reply_failed",
|
|
1228
|
+
request_id=request_id,
|
|
1229
|
+
session_id=event_session_id,
|
|
1230
|
+
exc=exc,
|
|
1231
|
+
)
|
|
1232
|
+
continue
|
|
1233
|
+
if event.event == "permission.asked":
|
|
1234
|
+
request_id, props = _extract_permission_request(payload)
|
|
1235
|
+
if request_id and respond_permission is not None:
|
|
1236
|
+
if (
|
|
1237
|
+
permission_policy == PERMISSION_ASK
|
|
1238
|
+
and permission_handler is not None
|
|
1239
|
+
):
|
|
1240
|
+
try:
|
|
1241
|
+
decision = await permission_handler(request_id, props)
|
|
1242
|
+
except Exception:
|
|
1243
|
+
decision = OPENCODE_PERMISSION_REJECT
|
|
1244
|
+
reply = _normalize_permission_decision(decision)
|
|
443
1245
|
else:
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
1246
|
+
reply = _permission_policy_reply(permission_policy)
|
|
1247
|
+
try:
|
|
1248
|
+
await respond_permission(request_id, reply)
|
|
1249
|
+
except Exception as exc:
|
|
1250
|
+
status_code = None
|
|
1251
|
+
body_preview = None
|
|
1252
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
1253
|
+
status_code = exc.response.status_code
|
|
1254
|
+
body_preview = (exc.response.text or "").strip()[
|
|
1255
|
+
:200
|
|
1256
|
+
] or None
|
|
1257
|
+
if (
|
|
1258
|
+
status_code is not None
|
|
1259
|
+
and 400 <= status_code < 500
|
|
1260
|
+
and request_id not in logged_permission_errors
|
|
1261
|
+
):
|
|
1262
|
+
logged_permission_errors.add(request_id)
|
|
1263
|
+
log_event(
|
|
1264
|
+
logger,
|
|
1265
|
+
logging.ERROR,
|
|
1266
|
+
"opencode.permission.reply_failed",
|
|
1267
|
+
request_id=request_id,
|
|
1268
|
+
reply=reply,
|
|
1269
|
+
status_code=status_code,
|
|
1270
|
+
body_preview=body_preview,
|
|
1271
|
+
session_id=event_session_id,
|
|
1272
|
+
)
|
|
1273
|
+
else:
|
|
1274
|
+
log_event(
|
|
1275
|
+
logger,
|
|
1276
|
+
logging.ERROR,
|
|
1277
|
+
"opencode.permission.reply_failed",
|
|
1278
|
+
request_id=request_id,
|
|
1279
|
+
reply=reply,
|
|
1280
|
+
session_id=event_session_id,
|
|
1281
|
+
exc=exc,
|
|
1282
|
+
)
|
|
1283
|
+
if is_primary_session:
|
|
1284
|
+
detail = body_preview or _extract_error_text(payload)
|
|
1285
|
+
error = "OpenCode permission reply failed"
|
|
1286
|
+
if status_code is not None:
|
|
1287
|
+
error = f"{error} ({status_code})"
|
|
1288
|
+
if detail:
|
|
1289
|
+
error = f"{error}: {detail}"
|
|
1290
|
+
break
|
|
1291
|
+
if event.event == "session.error":
|
|
1292
|
+
if is_primary_session:
|
|
1293
|
+
error = _extract_error_text(payload) or "OpenCode session error"
|
|
1294
|
+
break
|
|
1295
|
+
continue
|
|
1296
|
+
if event.event in ("message.updated", "message.completed"):
|
|
1297
|
+
if is_primary_session:
|
|
1298
|
+
msg_id, role = _register_message_role(payload)
|
|
1299
|
+
if role == "assistant":
|
|
1300
|
+
_flush_pending_text(msg_id)
|
|
1301
|
+
if event.event == "message.part.updated":
|
|
1302
|
+
properties = (
|
|
1303
|
+
payload.get("properties") if isinstance(payload, dict) else None
|
|
1304
|
+
)
|
|
1305
|
+
if isinstance(properties, dict):
|
|
1306
|
+
part = properties.get("part")
|
|
1307
|
+
delta = properties.get("delta")
|
|
1308
|
+
else:
|
|
1309
|
+
part = payload.get("part")
|
|
1310
|
+
delta = payload.get("delta")
|
|
1311
|
+
part_dict = part if isinstance(part, dict) else None
|
|
1312
|
+
part_with_session = None
|
|
1313
|
+
if isinstance(part_dict, dict):
|
|
1314
|
+
part_with_session = dict(part_dict)
|
|
1315
|
+
part_with_session["sessionID"] = event_session_id
|
|
1316
|
+
part_type = part_dict.get("type") if part_dict else None
|
|
1317
|
+
part_ignored = bool(part_dict.get("ignored")) if part_dict else False
|
|
1318
|
+
part_message_id = _message_id_from_part(part_dict)
|
|
1319
|
+
part_id = None
|
|
1320
|
+
if part_dict:
|
|
1321
|
+
part_id = part_dict.get("id") or part_dict.get("partId")
|
|
1322
|
+
if (
|
|
1323
|
+
isinstance(part_id, str)
|
|
1324
|
+
and part_id
|
|
1325
|
+
and isinstance(part_type, str)
|
|
1326
|
+
):
|
|
1327
|
+
part_types[part_id] = part_type
|
|
1328
|
+
elif (
|
|
1329
|
+
isinstance(part_id, str)
|
|
1330
|
+
and part_id
|
|
1331
|
+
and not isinstance(part_type, str)
|
|
1332
|
+
and part_id in part_types
|
|
1333
|
+
):
|
|
1334
|
+
part_type = part_types[part_id]
|
|
1335
|
+
if isinstance(delta, dict):
|
|
1336
|
+
delta_text = delta.get("text")
|
|
1337
|
+
elif isinstance(delta, str):
|
|
1338
|
+
delta_text = delta
|
|
1339
|
+
else:
|
|
1340
|
+
delta_text = None
|
|
1341
|
+
if isinstance(delta_text, str) and delta_text:
|
|
1342
|
+
if part_type == "reasoning":
|
|
1343
|
+
if part_handler and part_dict:
|
|
1344
|
+
await part_handler(
|
|
1345
|
+
"reasoning", part_with_session or part_dict, delta_text
|
|
1346
|
+
)
|
|
1347
|
+
elif part_type in (None, "text") and not part_ignored:
|
|
1348
|
+
if not is_primary_session:
|
|
1349
|
+
continue
|
|
1350
|
+
_append_text_for_message(part_message_id, delta_text)
|
|
1351
|
+
# Update dedupe bookkeeping for text deltas to prevent re-adding later
|
|
1352
|
+
if isinstance(part_dict, dict):
|
|
1353
|
+
part_id = part_dict.get("id") or part_dict.get("partId")
|
|
1354
|
+
text = part_dict.get("text")
|
|
1355
|
+
if (
|
|
1356
|
+
isinstance(part_id, str)
|
|
1357
|
+
and part_id
|
|
1358
|
+
and isinstance(text, str)
|
|
1359
|
+
):
|
|
1360
|
+
part_lengths[part_id] = len(text)
|
|
1361
|
+
elif isinstance(text, str):
|
|
1362
|
+
last_full_text = text
|
|
1363
|
+
if part_handler and part_dict:
|
|
1364
|
+
await part_handler(
|
|
1365
|
+
"text", part_with_session or part_dict, delta_text
|
|
1366
|
+
)
|
|
1367
|
+
elif part_handler and part_dict and part_type:
|
|
1368
|
+
await part_handler(
|
|
1369
|
+
part_type, part_with_session or part_dict, delta_text
|
|
1370
|
+
)
|
|
1371
|
+
elif (
|
|
1372
|
+
isinstance(part_dict, dict)
|
|
1373
|
+
and part_type in (None, "text")
|
|
1374
|
+
and not part_ignored
|
|
1375
|
+
):
|
|
1376
|
+
if not is_primary_session:
|
|
1377
|
+
continue
|
|
1378
|
+
text = part_dict.get("text")
|
|
1379
|
+
if isinstance(text, str) and text:
|
|
1380
|
+
part_id = part_dict.get("id") or part_dict.get("partId")
|
|
1381
|
+
if isinstance(part_id, str) and part_id:
|
|
1382
|
+
last_len = part_lengths.get(part_id, 0)
|
|
1383
|
+
if len(text) > last_len:
|
|
1384
|
+
_append_text_for_message(
|
|
1385
|
+
part_message_id, text[last_len:]
|
|
1386
|
+
)
|
|
1387
|
+
part_lengths[part_id] = len(text)
|
|
1388
|
+
else:
|
|
1389
|
+
if last_full_text and text.startswith(last_full_text):
|
|
1390
|
+
_append_text_for_message(
|
|
1391
|
+
part_message_id, text[len(last_full_text) :]
|
|
1392
|
+
)
|
|
1393
|
+
elif text != last_full_text:
|
|
1394
|
+
_append_text_for_message(part_message_id, text)
|
|
1395
|
+
last_full_text = text
|
|
1396
|
+
elif part_handler and part_dict and part_type:
|
|
1397
|
+
await part_handler(part_type, part_with_session or part_dict, None)
|
|
1398
|
+
if event.event in ("message.completed", "message.updated"):
|
|
1399
|
+
message_result = parse_message_response(payload)
|
|
1400
|
+
msg_id = None
|
|
1401
|
+
role = None
|
|
1402
|
+
if is_primary_session:
|
|
1403
|
+
msg_id, role = _register_message_role(payload)
|
|
1404
|
+
resolved_role = role
|
|
1405
|
+
if resolved_role is None and msg_id:
|
|
1406
|
+
resolved_role = message_roles.get(msg_id)
|
|
1407
|
+
if message_result.text:
|
|
1408
|
+
if resolved_role == "assistant" or resolved_role is None:
|
|
1409
|
+
fallback_message = (
|
|
1410
|
+
msg_id,
|
|
1411
|
+
resolved_role,
|
|
1412
|
+
message_result.text,
|
|
1413
|
+
)
|
|
1414
|
+
if resolved_role is None:
|
|
1415
|
+
log_event(
|
|
1416
|
+
logger,
|
|
1417
|
+
logging.DEBUG,
|
|
1418
|
+
"opencode.message.completed.role_missing",
|
|
1419
|
+
session_id=event_session_id,
|
|
1420
|
+
message_id=msg_id,
|
|
1421
|
+
)
|
|
1422
|
+
else:
|
|
1423
|
+
log_event(
|
|
1424
|
+
logger,
|
|
1425
|
+
logging.DEBUG,
|
|
1426
|
+
"opencode.message.completed.ignored",
|
|
1427
|
+
session_id=event_session_id,
|
|
1428
|
+
message_id=msg_id,
|
|
1429
|
+
role=resolved_role,
|
|
447
1430
|
)
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
1431
|
+
if message_result.error and not error:
|
|
1432
|
+
error = message_result.error
|
|
1433
|
+
if part_handler is not None and is_primary_session:
|
|
1434
|
+
usage = _extract_usage_payload(payload)
|
|
1435
|
+
if usage is not None:
|
|
1436
|
+
provider_id, model_id = _extract_model_ids(payload)
|
|
1437
|
+
if not provider_id or not model_id:
|
|
1438
|
+
provider_id, model_id = await _resolve_session_model_ids()
|
|
1439
|
+
total_tokens = _extract_total_tokens(usage)
|
|
1440
|
+
context_window = _extract_context_window(payload, usage)
|
|
1441
|
+
if context_window is None:
|
|
1442
|
+
context_window = (
|
|
1443
|
+
await _resolve_context_window_from_providers(
|
|
1444
|
+
provider_id, model_id
|
|
1445
|
+
)
|
|
1446
|
+
)
|
|
1447
|
+
usage_details = _extract_usage_details(usage)
|
|
1448
|
+
if (
|
|
1449
|
+
total_tokens != last_usage_total
|
|
1450
|
+
or context_window != last_context_window
|
|
1451
|
+
):
|
|
1452
|
+
last_usage_total = total_tokens
|
|
1453
|
+
last_context_window = context_window
|
|
1454
|
+
usage_snapshot: dict[str, Any] = {}
|
|
1455
|
+
if provider_id:
|
|
1456
|
+
usage_snapshot["providerID"] = provider_id
|
|
1457
|
+
if model_id:
|
|
1458
|
+
usage_snapshot["modelID"] = model_id
|
|
1459
|
+
if total_tokens is not None:
|
|
1460
|
+
usage_snapshot["totalTokens"] = total_tokens
|
|
1461
|
+
if usage_details:
|
|
1462
|
+
usage_snapshot.update(usage_details)
|
|
1463
|
+
if context_window is not None:
|
|
1464
|
+
usage_snapshot["modelContextWindow"] = context_window
|
|
1465
|
+
if usage_snapshot:
|
|
1466
|
+
await part_handler("usage", usage_snapshot, None)
|
|
1467
|
+
if event.event == "session.idle" or (
|
|
1468
|
+
event.event == "session.status"
|
|
1469
|
+
and _status_is_idle(_extract_status_type(payload))
|
|
1470
|
+
):
|
|
1471
|
+
if not is_primary_session:
|
|
1472
|
+
continue
|
|
1473
|
+
if not text_parts and pending_text:
|
|
1474
|
+
_flush_all_pending_text()
|
|
1475
|
+
break
|
|
1476
|
+
if event.event == "message.completed" and is_primary_session:
|
|
1477
|
+
last_primary_completion_at = time.monotonic()
|
|
1478
|
+
finally:
|
|
1479
|
+
await _close_stream(stream_iter)
|
|
1480
|
+
|
|
1481
|
+
if not text_parts and fallback_message is not None:
|
|
1482
|
+
msg_id, role, text = fallback_message
|
|
1483
|
+
resolved_role = role
|
|
1484
|
+
if resolved_role is None and msg_id:
|
|
1485
|
+
resolved_role = message_roles.get(msg_id)
|
|
1486
|
+
if resolved_role == "assistant":
|
|
1487
|
+
_append_text_for_message(msg_id, text)
|
|
1488
|
+
if pending_text:
|
|
462
1489
|
_flush_all_pending_text()
|
|
463
|
-
break
|
|
464
1490
|
|
|
465
1491
|
return OpenCodeTurnOutput(text="".join(text_parts).strip(), error=error)
|
|
466
1492
|
|
|
@@ -470,22 +1496,65 @@ async def collect_opencode_output(
|
|
|
470
1496
|
*,
|
|
471
1497
|
session_id: str,
|
|
472
1498
|
workspace_path: str,
|
|
1499
|
+
model_payload: Optional[dict[str, str]] = None,
|
|
1500
|
+
progress_session_ids: Optional[set[str]] = None,
|
|
473
1501
|
permission_policy: str = PERMISSION_ALLOW,
|
|
474
1502
|
permission_handler: Optional[PermissionHandler] = None,
|
|
1503
|
+
question_policy: str = "ignore",
|
|
1504
|
+
question_handler: Optional[QuestionHandler] = None,
|
|
475
1505
|
should_stop: Optional[Callable[[], bool]] = None,
|
|
1506
|
+
ready_event: Optional[Any] = None,
|
|
476
1507
|
part_handler: Optional[PartHandler] = None,
|
|
1508
|
+
stall_timeout_seconds: Optional[float] = _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS,
|
|
477
1509
|
) -> OpenCodeTurnOutput:
|
|
478
1510
|
async def _respond(request_id: str, reply: str) -> None:
|
|
479
1511
|
await client.respond_permission(request_id=request_id, reply=reply)
|
|
480
1512
|
|
|
1513
|
+
async def _reply_question(request_id: str, answers: list[list[str]]) -> None:
|
|
1514
|
+
await client.reply_question(request_id, answers=answers)
|
|
1515
|
+
|
|
1516
|
+
async def _reject_question(request_id: str) -> None:
|
|
1517
|
+
await client.reject_question(request_id)
|
|
1518
|
+
|
|
1519
|
+
def _stream_factory() -> AsyncIterator[SSEEvent]:
|
|
1520
|
+
return cast(
|
|
1521
|
+
AsyncIterator[SSEEvent],
|
|
1522
|
+
client.stream_events(directory=workspace_path, ready_event=ready_event),
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
async def _fetch_session() -> Any:
|
|
1526
|
+
statuses = await client.session_status(directory=workspace_path)
|
|
1527
|
+
if isinstance(statuses, dict):
|
|
1528
|
+
session_status = statuses.get(session_id)
|
|
1529
|
+
if session_status is None:
|
|
1530
|
+
return {"status": {"type": "idle"}}
|
|
1531
|
+
if isinstance(session_status, dict):
|
|
1532
|
+
return {"status": session_status}
|
|
1533
|
+
if isinstance(session_status, str):
|
|
1534
|
+
return {"status": session_status}
|
|
1535
|
+
return {"status": {}}
|
|
1536
|
+
|
|
1537
|
+
async def _fetch_providers() -> Any:
|
|
1538
|
+
return await client.providers(directory=workspace_path)
|
|
1539
|
+
|
|
481
1540
|
return await collect_opencode_output_from_events(
|
|
482
|
-
|
|
1541
|
+
None,
|
|
483
1542
|
session_id=session_id,
|
|
1543
|
+
progress_session_ids=progress_session_ids,
|
|
484
1544
|
permission_policy=permission_policy,
|
|
485
1545
|
permission_handler=permission_handler,
|
|
1546
|
+
question_policy=question_policy,
|
|
1547
|
+
question_handler=question_handler,
|
|
486
1548
|
should_stop=should_stop,
|
|
487
1549
|
respond_permission=_respond,
|
|
1550
|
+
reply_question=_reply_question,
|
|
1551
|
+
reject_question=_reject_question,
|
|
488
1552
|
part_handler=part_handler,
|
|
1553
|
+
event_stream_factory=_stream_factory,
|
|
1554
|
+
model_payload=model_payload,
|
|
1555
|
+
session_fetcher=_fetch_session,
|
|
1556
|
+
provider_fetcher=_fetch_providers,
|
|
1557
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
489
1558
|
)
|
|
490
1559
|
|
|
491
1560
|
|
|
@@ -505,5 +1574,6 @@ __all__ = [
|
|
|
505
1574
|
"opencode_missing_env",
|
|
506
1575
|
"parse_message_response",
|
|
507
1576
|
"PartHandler",
|
|
1577
|
+
"QuestionHandler",
|
|
508
1578
|
"split_model_id",
|
|
509
1579
|
]
|