flowent 0.2.2 → 0.2.4
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.
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +1 -0
- package/backend/src/flowent/context.py +2 -0
- package/backend/src/flowent/llm.py +13 -12
- package/backend/src/flowent/logging.py +7 -1
- package/backend/src/flowent/main.py +232 -16
- package/backend/src/flowent/mcp.py +131 -42
- package/backend/src/flowent/permissions.py +10 -3
- package/backend/src/flowent/sandbox.py +1 -1
- package/backend/src/flowent/shell.py +94 -0
- package/backend/src/flowent/static/assets/index-BH30iLzb.css +2 -0
- package/backend/src/flowent/static/assets/index-sBFt3ORj.js +84 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +64 -0
- package/backend/src/flowent/tools.py +5 -2
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BH30iLzb.css +2 -0
- package/dist/frontend/assets/index-sBFt3ORj.js +84 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-Bz76A4EJ.js +0 -82
- package/backend/src/flowent/static/assets/index-DufpDl8x.css +0 -2
- package/dist/frontend/assets/index-Bz76A4EJ.js +0 -82
- package/dist/frontend/assets/index-DufpDl8x.css +0 -2
package/backend/pyproject.toml
CHANGED
|
@@ -240,6 +240,7 @@ async def run_agent_stream(
|
|
|
240
240
|
round_number,
|
|
241
241
|
tool_calls,
|
|
242
242
|
)
|
|
243
|
+
yield AgentStreamEvent(event="output_done", data={"index": round_number})
|
|
243
244
|
if not tool_calls:
|
|
244
245
|
if not final_content and not final_thinking:
|
|
245
246
|
raise RuntimeError(EMPTY_MODEL_RESPONSE_ERROR)
|
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
from flowent.llm import ChatMessage
|
|
7
|
+
from flowent.shell import shell_invocation_description
|
|
7
8
|
from flowent.tools import tool_specs
|
|
8
9
|
|
|
9
10
|
DEFAULT_PROJECT_INSTRUCTIONS_MAX_BYTES = 32768
|
|
@@ -108,6 +109,7 @@ def environment_context_message(cwd: Path) -> ChatMessage:
|
|
|
108
109
|
content=(
|
|
109
110
|
"<environment_context>\n"
|
|
110
111
|
f" <cwd>{cwd.resolve(strict=False)}</cwd>\n"
|
|
112
|
+
f" <shell>{shell_invocation_description()}</shell>\n"
|
|
111
113
|
" <filesystem>workspace-write</filesystem>\n"
|
|
112
114
|
" <network>enabled</network>\n"
|
|
113
115
|
" <tools>\n"
|
|
@@ -108,6 +108,7 @@ MODEL_PREFIXES: dict[ProviderFormat, str] = {
|
|
|
108
108
|
ProviderFormat.ANTHROPIC: "anthropic",
|
|
109
109
|
ProviderFormat.GEMINI: "gemini",
|
|
110
110
|
}
|
|
111
|
+
OPENAI_RESPONSES_MODEL_PREFIX = "responses/"
|
|
111
112
|
_litellm_stream_error_patch_installed = False
|
|
112
113
|
|
|
113
114
|
PROVIDER_API_VERSIONS: dict[ProviderFormat, str] = {
|
|
@@ -121,7 +122,10 @@ VERSION_PATH_SEGMENT = re.compile(r"^v\d+(?:[a-z]+)?$", re.IGNORECASE)
|
|
|
121
122
|
|
|
122
123
|
|
|
123
124
|
def provider_model_name(connection: ProviderConnection) -> str:
|
|
124
|
-
|
|
125
|
+
model = normalize_provider_model_name(connection.provider, connection.model)
|
|
126
|
+
if connection.provider == ProviderFormat.OPENAI_RESPONSES:
|
|
127
|
+
model = f"{OPENAI_RESPONSES_MODEL_PREFIX}{model}"
|
|
128
|
+
return f"{MODEL_PREFIXES[connection.provider]}/{model}"
|
|
125
129
|
|
|
126
130
|
|
|
127
131
|
def provider_litellm_name(provider: ProviderFormat) -> str:
|
|
@@ -164,23 +168,20 @@ def normalize_provider_base_url(
|
|
|
164
168
|
|
|
165
169
|
def normalize_provider_model_name(provider: ProviderFormat, model: str) -> str:
|
|
166
170
|
prefix = f"{provider_litellm_name(provider)}/"
|
|
167
|
-
if model.startswith(prefix)
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
normalized_model = model.removeprefix(prefix) if model.startswith(prefix) else model
|
|
172
|
+
if provider == ProviderFormat.OPENAI_RESPONSES:
|
|
173
|
+
return normalized_model.removeprefix(OPENAI_RESPONSES_MODEL_PREFIX)
|
|
174
|
+
return normalized_model
|
|
170
175
|
|
|
171
176
|
|
|
172
177
|
def stream_failure_message(chunk: Any) -> str:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if not isinstance(chunk, Mapping):
|
|
176
|
-
return ""
|
|
177
|
-
|
|
178
|
-
event_type = getattr(chunk.get("type"), "value", chunk.get("type"))
|
|
178
|
+
event_type = value_at(chunk, "type", "")
|
|
179
|
+
event_type = getattr(event_type, "value", event_type)
|
|
179
180
|
event_type = str(event_type or "")
|
|
180
181
|
if event_type == "error":
|
|
181
|
-
error = chunk
|
|
182
|
+
error = value_at(chunk, "error", {})
|
|
182
183
|
elif event_type == "response.failed":
|
|
183
|
-
response = chunk
|
|
184
|
+
response = value_at(chunk, "response", {})
|
|
184
185
|
error = value_at(response, "error", {})
|
|
185
186
|
else:
|
|
186
187
|
return ""
|
|
@@ -194,6 +194,12 @@ class ConsoleNoiseFilter(logging.Filter):
|
|
|
194
194
|
return record.levelno > logging.DEBUG or record.name.startswith("flowent")
|
|
195
195
|
|
|
196
196
|
|
|
197
|
+
class ConsoleHandler(logging.StreamHandler):
|
|
198
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
199
|
+
self.setStream(sys.stderr if record.levelno >= logging.WARNING else sys.stdout)
|
|
200
|
+
super().emit(record)
|
|
201
|
+
|
|
202
|
+
|
|
197
203
|
def configure_logging(*, directory: Path | None = None) -> Path:
|
|
198
204
|
global _configured_log_file, _configured_log_process_id
|
|
199
205
|
|
|
@@ -218,7 +224,7 @@ def configure_logging(*, directory: Path | None = None) -> Path:
|
|
|
218
224
|
)
|
|
219
225
|
)
|
|
220
226
|
|
|
221
|
-
console_handler =
|
|
227
|
+
console_handler = ConsoleHandler()
|
|
222
228
|
console_handler.setLevel(console_log_level())
|
|
223
229
|
console_handler.setFormatter(
|
|
224
230
|
RedactingFormatter("%(levelname)s %(name)s: %(message)s")
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import copy
|
|
2
3
|
import json
|
|
3
4
|
import logging
|
|
4
5
|
import os
|
|
5
|
-
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
|
|
6
8
|
from contextlib import asynccontextmanager, suppress
|
|
7
9
|
from dataclasses import dataclass, field
|
|
8
10
|
from pathlib import Path
|
|
9
|
-
from typing import Literal
|
|
11
|
+
from typing import Any, Literal
|
|
10
12
|
from uuid import uuid4
|
|
11
13
|
|
|
12
14
|
from fastapi import FastAPI, HTTPException, Query
|
|
@@ -89,6 +91,7 @@ DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO = 0.95
|
|
|
89
91
|
AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET = 20_000
|
|
90
92
|
APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
|
|
91
93
|
APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
|
|
94
|
+
WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS = 0.5
|
|
92
95
|
|
|
93
96
|
|
|
94
97
|
class ProviderModelsRequest(BaseModel):
|
|
@@ -180,6 +183,7 @@ class WritablePathListResponse(BaseModel):
|
|
|
180
183
|
@dataclass
|
|
181
184
|
class WorkspaceRun:
|
|
182
185
|
condition: asyncio.Condition
|
|
186
|
+
active_output: Literal["text", "thinking"] | None = None
|
|
183
187
|
discard_on_cancel: bool = False
|
|
184
188
|
events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
|
|
185
189
|
generation: int = 0
|
|
@@ -200,8 +204,13 @@ def stream_event(
|
|
|
200
204
|
return f"{id_line}event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
201
205
|
|
|
202
206
|
|
|
203
|
-
def stream_message_data(
|
|
204
|
-
|
|
207
|
+
def stream_message_data(
|
|
208
|
+
message: StoredMessage, active_output: Literal["text", "thinking"] | None = None
|
|
209
|
+
) -> dict[str, object]:
|
|
210
|
+
data = {**message.model_dump(), "status": message.status}
|
|
211
|
+
if active_output is not None:
|
|
212
|
+
data["active_output"] = active_output
|
|
213
|
+
return data
|
|
205
214
|
|
|
206
215
|
|
|
207
216
|
def append_or_replace_message(
|
|
@@ -216,13 +225,131 @@ def append_or_replace_message(
|
|
|
216
225
|
def run_snapshot_data_at(
|
|
217
226
|
run: WorkspaceRun, event_index: int
|
|
218
227
|
) -> dict[str, object] | None:
|
|
219
|
-
|
|
220
|
-
|
|
228
|
+
snapshot_event_index = 0
|
|
229
|
+
snapshot: dict[str, object] | None = None
|
|
230
|
+
for current_event_index, event, data in run.events:
|
|
231
|
+
if current_event_index > event_index:
|
|
232
|
+
break
|
|
233
|
+
if event != "snapshot":
|
|
234
|
+
if event == "start" and snapshot is None:
|
|
235
|
+
assistant_id = data.get("id")
|
|
236
|
+
if isinstance(assistant_id, str):
|
|
237
|
+
snapshot_event_index = current_event_index
|
|
238
|
+
snapshot = {
|
|
239
|
+
"author": "assistant",
|
|
240
|
+
"content": "",
|
|
241
|
+
"groups": [],
|
|
242
|
+
"id": assistant_id,
|
|
243
|
+
"status": "running",
|
|
244
|
+
"tools": [],
|
|
245
|
+
}
|
|
221
246
|
continue
|
|
222
247
|
message = data.get("message")
|
|
223
248
|
if isinstance(message, dict):
|
|
224
|
-
|
|
225
|
-
|
|
249
|
+
snapshot_event_index = current_event_index
|
|
250
|
+
snapshot = copy.deepcopy(message)
|
|
251
|
+
if snapshot is None:
|
|
252
|
+
return None
|
|
253
|
+
for current_event_index, event, data in run.events:
|
|
254
|
+
if current_event_index <= snapshot_event_index:
|
|
255
|
+
continue
|
|
256
|
+
if current_event_index > event_index:
|
|
257
|
+
break
|
|
258
|
+
apply_stream_event_to_snapshot(snapshot, event, data)
|
|
259
|
+
return snapshot
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def apply_stream_event_to_snapshot(
|
|
263
|
+
snapshot: dict[str, object], event: str, data: dict[str, object]
|
|
264
|
+
) -> None:
|
|
265
|
+
if event == "output_start":
|
|
266
|
+
snapshot.pop("active_output", None)
|
|
267
|
+
index = data.get("index")
|
|
268
|
+
if isinstance(index, int):
|
|
269
|
+
append_snapshot_group(snapshot, index)
|
|
270
|
+
if event == "delta":
|
|
271
|
+
append_snapshot_text(snapshot, str(data.get("content") or ""))
|
|
272
|
+
if event == "thinking_delta":
|
|
273
|
+
append_snapshot_thinking(snapshot, str(data.get("content") or ""))
|
|
274
|
+
if event == "output_done":
|
|
275
|
+
snapshot.pop("active_output", None)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def snapshot_groups(snapshot: dict[str, object]) -> list[dict[str, object]]:
|
|
279
|
+
groups = snapshot.get("groups")
|
|
280
|
+
if not isinstance(groups, list):
|
|
281
|
+
groups = []
|
|
282
|
+
snapshot["groups"] = groups
|
|
283
|
+
return groups
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def append_snapshot_group(
|
|
287
|
+
snapshot: dict[str, object], index: int | None = None
|
|
288
|
+
) -> None:
|
|
289
|
+
groups = snapshot_groups(snapshot)
|
|
290
|
+
assistant_id = str(snapshot.get("id") or "assistant")
|
|
291
|
+
group_index = index if index is not None else len(groups) + 1
|
|
292
|
+
group_id = f"{assistant_id}-group-{group_index}"
|
|
293
|
+
if groups and groups[-1].get("id") == group_id:
|
|
294
|
+
return
|
|
295
|
+
groups.append({"id": group_id, "items": []})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def append_snapshot_text(snapshot: dict[str, object], content: str) -> None:
|
|
299
|
+
if not content:
|
|
300
|
+
return
|
|
301
|
+
snapshot["active_output"] = "text"
|
|
302
|
+
snapshot["content"] = f"{snapshot.get('content') or ''}{content}"
|
|
303
|
+
append_snapshot_item_content(snapshot, content, "text")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def append_snapshot_thinking(snapshot: dict[str, object], content: str) -> None:
|
|
307
|
+
if not content:
|
|
308
|
+
return
|
|
309
|
+
snapshot["active_output"] = "thinking"
|
|
310
|
+
snapshot["thinking"] = f"{snapshot.get('thinking') or ''}{content}"
|
|
311
|
+
append_snapshot_item_content(snapshot, content, "thinking")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def append_snapshot_item_content(
|
|
315
|
+
snapshot: dict[str, object], content: str, item_type: Literal["text", "thinking"]
|
|
316
|
+
) -> None:
|
|
317
|
+
groups = snapshot_groups(snapshot)
|
|
318
|
+
if not groups:
|
|
319
|
+
append_snapshot_group(snapshot)
|
|
320
|
+
group = groups[-1]
|
|
321
|
+
items = group.get("items")
|
|
322
|
+
if not isinstance(items, list):
|
|
323
|
+
items = []
|
|
324
|
+
group["items"] = items
|
|
325
|
+
item = next(
|
|
326
|
+
(
|
|
327
|
+
current
|
|
328
|
+
for current in reversed(items)
|
|
329
|
+
if isinstance(current, dict) and current.get("type") == item_type
|
|
330
|
+
),
|
|
331
|
+
None,
|
|
332
|
+
)
|
|
333
|
+
if item is None:
|
|
334
|
+
assistant_id = str(snapshot.get("id") or "assistant")
|
|
335
|
+
snapshot_item_count = 0
|
|
336
|
+
for current_group in groups:
|
|
337
|
+
current_items = current_group.get("items")
|
|
338
|
+
if not isinstance(current_items, list):
|
|
339
|
+
continue
|
|
340
|
+
snapshot_item_count += sum(
|
|
341
|
+
1
|
|
342
|
+
for current_item in current_items
|
|
343
|
+
if isinstance(current_item, dict)
|
|
344
|
+
and current_item.get("type") == item_type
|
|
345
|
+
)
|
|
346
|
+
item = {
|
|
347
|
+
"content": "",
|
|
348
|
+
"id": f"{assistant_id}-{item_type}-{snapshot_item_count + 1}",
|
|
349
|
+
"type": item_type,
|
|
350
|
+
}
|
|
351
|
+
items.append(item)
|
|
352
|
+
item["content"] = f"{item.get('content') or ''}{content}"
|
|
226
353
|
|
|
227
354
|
|
|
228
355
|
USER_VISIBLE_RUN_ERROR_TITLE = "Request failed"
|
|
@@ -1007,6 +1134,58 @@ def create_app(
|
|
|
1007
1134
|
telegram_transport=telegram_transport,
|
|
1008
1135
|
)
|
|
1009
1136
|
|
|
1137
|
+
async def gather_shutdown_tasks(
|
|
1138
|
+
label: str, tasks: Sequence[asyncio.Task[Any]]
|
|
1139
|
+
) -> None:
|
|
1140
|
+
if not tasks:
|
|
1141
|
+
return
|
|
1142
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
1143
|
+
for result in results:
|
|
1144
|
+
if result is None or isinstance(result, asyncio.CancelledError):
|
|
1145
|
+
continue
|
|
1146
|
+
if isinstance(result, BaseException):
|
|
1147
|
+
logger.error(
|
|
1148
|
+
"%s cleanup task failed",
|
|
1149
|
+
label,
|
|
1150
|
+
exc_info=(type(result), result, result.__traceback__),
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
async def stop_workspace_runs_for_shutdown() -> None:
|
|
1154
|
+
tasks: list[asyncio.Task[None]] = []
|
|
1155
|
+
for run in workspace_runs.values():
|
|
1156
|
+
if run.task is None or run.task.done():
|
|
1157
|
+
continue
|
|
1158
|
+
run.task.cancel()
|
|
1159
|
+
tasks.append(run.task)
|
|
1160
|
+
await gather_shutdown_tasks("Workspace run", tasks)
|
|
1161
|
+
|
|
1162
|
+
async def stop_workspace_compact_for_shutdown() -> None:
|
|
1163
|
+
nonlocal active_compact_task
|
|
1164
|
+
if active_compact_task is None:
|
|
1165
|
+
store.save_is_compacting(False)
|
|
1166
|
+
return
|
|
1167
|
+
task = active_compact_task.task
|
|
1168
|
+
active_compact_task = None
|
|
1169
|
+
if not task.done():
|
|
1170
|
+
task.cancel()
|
|
1171
|
+
await gather_shutdown_tasks("Workspace compact", [task])
|
|
1172
|
+
store.save_is_compacting(False)
|
|
1173
|
+
|
|
1174
|
+
async def run_shutdown_step(label: str, cleanup: Awaitable[object]) -> None:
|
|
1175
|
+
try:
|
|
1176
|
+
await cleanup
|
|
1177
|
+
except Exception:
|
|
1178
|
+
logger.exception("%s cleanup failed during shutdown", label)
|
|
1179
|
+
|
|
1180
|
+
async def graceful_shutdown() -> None:
|
|
1181
|
+
await run_shutdown_step("Workspace run", stop_workspace_runs_for_shutdown())
|
|
1182
|
+
await run_shutdown_step(
|
|
1183
|
+
"Workspace compact", stop_workspace_compact_for_shutdown()
|
|
1184
|
+
)
|
|
1185
|
+
if telegram_bot_manager is not None:
|
|
1186
|
+
await run_shutdown_step("Telegram", telegram_bot_manager.stop_all())
|
|
1187
|
+
await run_shutdown_step("MCP", mcp_manager.stop_all())
|
|
1188
|
+
|
|
1010
1189
|
@asynccontextmanager
|
|
1011
1190
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
1012
1191
|
app.state.mcp_manager = mcp_manager
|
|
@@ -1017,9 +1196,7 @@ def create_app(
|
|
|
1017
1196
|
try:
|
|
1018
1197
|
yield
|
|
1019
1198
|
finally:
|
|
1020
|
-
|
|
1021
|
-
await telegram_bot_manager.stop_all()
|
|
1022
|
-
await mcp_manager.stop_all()
|
|
1199
|
+
await graceful_shutdown()
|
|
1023
1200
|
|
|
1024
1201
|
app = FastAPI(title="Flowent", lifespan=lifespan)
|
|
1025
1202
|
app.state.mcp_manager = mcp_manager
|
|
@@ -1061,6 +1238,11 @@ def create_app(
|
|
|
1061
1238
|
async def save_provider(provider: StoredProvider) -> StoredProvider:
|
|
1062
1239
|
return store.save_provider(provider)
|
|
1063
1240
|
|
|
1241
|
+
@app.delete("/api/providers/{provider_id}")
|
|
1242
|
+
async def delete_provider(provider_id: str) -> dict[str, bool]:
|
|
1243
|
+
store.delete_provider(provider_id)
|
|
1244
|
+
return {"ok": True}
|
|
1245
|
+
|
|
1064
1246
|
@app.put("/api/mcp/servers")
|
|
1065
1247
|
async def save_mcp_server(server: StoredMcpServer) -> StoredMcpServer:
|
|
1066
1248
|
saved_server = store.save_mcp_server(server)
|
|
@@ -1204,7 +1386,7 @@ def create_app(
|
|
|
1204
1386
|
await append_run_event(
|
|
1205
1387
|
run,
|
|
1206
1388
|
"snapshot",
|
|
1207
|
-
{"message": stream_message_data(message)},
|
|
1389
|
+
{"message": stream_message_data(message, run.active_output)},
|
|
1208
1390
|
)
|
|
1209
1391
|
|
|
1210
1392
|
def active_workspace_run() -> WorkspaceRun | None:
|
|
@@ -1258,11 +1440,14 @@ def create_app(
|
|
|
1258
1440
|
status="running",
|
|
1259
1441
|
)
|
|
1260
1442
|
assistant_output = AssistantOutputBuilder(assistant_message.id)
|
|
1443
|
+
last_progress_flush_at = 0.0
|
|
1261
1444
|
|
|
1262
1445
|
def is_current_generation() -> bool:
|
|
1263
1446
|
return run.generation == workspace_generation
|
|
1264
1447
|
|
|
1265
|
-
def
|
|
1448
|
+
def update_assistant_message(
|
|
1449
|
+
status: str = "running", *, persist: bool
|
|
1450
|
+
) -> StoredMessage | None:
|
|
1266
1451
|
nonlocal next_messages, assistant_message
|
|
1267
1452
|
if not is_current_generation() or run.discard_on_cancel:
|
|
1268
1453
|
return None
|
|
@@ -1279,9 +1464,33 @@ def create_app(
|
|
|
1279
1464
|
next_messages = append_or_replace_message(
|
|
1280
1465
|
next_messages, assistant_message
|
|
1281
1466
|
)
|
|
1282
|
-
|
|
1467
|
+
if persist:
|
|
1468
|
+
store.upsert_message(assistant_message)
|
|
1283
1469
|
return assistant_message
|
|
1284
1470
|
|
|
1471
|
+
def persist_assistant(status: str = "running") -> StoredMessage | None:
|
|
1472
|
+
nonlocal last_progress_flush_at
|
|
1473
|
+
message = update_assistant_message(status, persist=True)
|
|
1474
|
+
if status == "running" and message is not None:
|
|
1475
|
+
last_progress_flush_at = time.monotonic()
|
|
1476
|
+
return message
|
|
1477
|
+
|
|
1478
|
+
def refresh_assistant(status: str = "running") -> StoredMessage | None:
|
|
1479
|
+
return update_assistant_message(status, persist=False)
|
|
1480
|
+
|
|
1481
|
+
def persist_assistant_progress() -> StoredMessage | None:
|
|
1482
|
+
nonlocal last_progress_flush_at
|
|
1483
|
+
now = time.monotonic()
|
|
1484
|
+
if (
|
|
1485
|
+
last_progress_flush_at > 0
|
|
1486
|
+
and now - last_progress_flush_at
|
|
1487
|
+
< WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS
|
|
1488
|
+
):
|
|
1489
|
+
refresh_assistant()
|
|
1490
|
+
return None
|
|
1491
|
+
last_progress_flush_at = now
|
|
1492
|
+
return update_assistant_message("running", persist=True)
|
|
1493
|
+
|
|
1285
1494
|
try:
|
|
1286
1495
|
current_tool_id: str | None = None
|
|
1287
1496
|
turn_usage_info: TokenUsageInfo | None = None
|
|
@@ -1445,11 +1654,15 @@ def create_app(
|
|
|
1445
1654
|
if event.event == "output_start":
|
|
1446
1655
|
index = event.data.get("index")
|
|
1447
1656
|
if isinstance(index, int):
|
|
1657
|
+
run.active_output = None
|
|
1448
1658
|
assistant_output.start_group(index)
|
|
1449
1659
|
snapshot_after_event = persist_assistant()
|
|
1660
|
+
if event.event == "output_done":
|
|
1661
|
+
run.active_output = None
|
|
1450
1662
|
if event.event == "tool_start":
|
|
1451
1663
|
tool = event.data.get("tool")
|
|
1452
1664
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
1665
|
+
run.active_output = None
|
|
1453
1666
|
current_tool_id = tool["id"]
|
|
1454
1667
|
assistant_output.start_tool(
|
|
1455
1668
|
StoredToolItem.model_validate(tool)
|
|
@@ -1467,15 +1680,17 @@ def create_app(
|
|
|
1467
1680
|
assistant_output.update_tool(tool_id, event.data)
|
|
1468
1681
|
snapshot_after_event = persist_assistant()
|
|
1469
1682
|
if event.event == "delta":
|
|
1683
|
+
run.active_output = "text"
|
|
1470
1684
|
assistant_output.append_text(
|
|
1471
1685
|
str(event.data.get("content") or "")
|
|
1472
1686
|
)
|
|
1473
|
-
snapshot_after_event =
|
|
1687
|
+
snapshot_after_event = persist_assistant_progress()
|
|
1474
1688
|
if event.event == "thinking_delta":
|
|
1689
|
+
run.active_output = "thinking"
|
|
1475
1690
|
assistant_output.append_thinking(
|
|
1476
1691
|
str(event.data.get("content") or "")
|
|
1477
1692
|
)
|
|
1478
|
-
snapshot_after_event =
|
|
1693
|
+
snapshot_after_event = persist_assistant_progress()
|
|
1479
1694
|
if event.event == "usage":
|
|
1480
1695
|
usage_data = event.data.get("usage")
|
|
1481
1696
|
if isinstance(usage_data, dict):
|
|
@@ -1503,6 +1718,7 @@ def create_app(
|
|
|
1503
1718
|
if event.event == "done":
|
|
1504
1719
|
message = event.data.get("message")
|
|
1505
1720
|
if isinstance(message, dict):
|
|
1721
|
+
run.active_output = None
|
|
1506
1722
|
assistant_output.apply_done_message(message)
|
|
1507
1723
|
response_usage_info = store.read_usage_info()
|
|
1508
1724
|
final_usage_info = turn_usage_info
|