flowent 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/agent.py +22 -15
  3. package/backend/src/flowent/api_models.py +13 -8
  4. package/backend/src/flowent/llm.py +50 -6
  5. package/backend/src/flowent/mcp.py +4 -3
  6. package/backend/src/flowent/permissions.py +51 -38
  7. package/backend/src/flowent/routes/providers.py +33 -10
  8. package/backend/src/flowent/routes/system.py +5 -6
  9. package/backend/src/flowent/routes/workspace.py +33 -23
  10. package/backend/src/flowent/state/models.py +4 -4
  11. package/backend/src/flowent/state/schema.py +121 -0
  12. package/backend/src/flowent/state/store.py +9 -3
  13. package/backend/src/flowent/static/assets/index-BX18a4Jz.js +100 -0
  14. package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
  15. package/backend/src/flowent/static/index.html +2 -2
  16. package/backend/src/flowent/tools.py +84 -33
  17. package/backend/src/flowent/usage.py +66 -0
  18. package/backend/src/flowent/workspace/context.py +140 -47
  19. package/backend/src/flowent/workspace/events.py +5 -7
  20. package/backend/src/flowent/workspace/output.py +129 -4
  21. package/backend/src/flowent/workspace/runtime.py +393 -185
  22. package/backend/uv.lock +1 -1
  23. package/dist/frontend/assets/index-BX18a4Jz.js +100 -0
  24. package/dist/frontend/assets/index-EC37agAH.css +2 -0
  25. package/dist/frontend/index.html +2 -2
  26. package/package.json +8 -10
  27. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +0 -2
  28. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +0 -90
  29. package/dist/frontend/assets/index-CvWZZMtK.css +0 -2
  30. package/dist/frontend/assets/index-ma2v8oW7.js +0 -90
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -25,6 +25,8 @@ from flowent.tools import (
25
25
  new_tool_item,
26
26
  parse_tool_arguments,
27
27
  run_tool_async,
28
+ text_tool_result,
29
+ tool_result_model_content,
28
30
  tool_specs,
29
31
  )
30
32
 
@@ -277,7 +279,12 @@ async def run_agent_stream(
277
279
  arguments = parse_tool_arguments(tool_call.arguments)
278
280
  except Exception as error:
279
281
  arguments = {}
280
- result_content = str(error)
282
+ result = ToolResult(
283
+ result=text_tool_result(str(error)),
284
+ ok=False,
285
+ title=tool_call.name or "Tool failed",
286
+ )
287
+ result_content = tool_result_model_content(result)
281
288
  tool_item = new_tool_item(tool_call.name, arguments)
282
289
  logger.debug("Tool call argument parse failed name=%s", tool_call.name)
283
290
  logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
@@ -292,10 +299,9 @@ async def run_agent_stream(
292
299
  event="tool_error",
293
300
  data={
294
301
  "id": tool_item["id"],
295
- "content": result_content,
296
- "data": {},
302
+ "result": result.result,
297
303
  "status": "failed",
298
- "title": tool_call.name or "Tool failed",
304
+ "title": result.title,
299
305
  },
300
306
  )
301
307
  else:
@@ -314,10 +320,12 @@ async def run_agent_stream(
314
320
  if extra_tool_runner is not None
315
321
  else None
316
322
  )
317
- result = extra_result if isinstance(extra_result, ToolResult) else None
318
- if result is None:
323
+ tool_result: ToolResult | None = (
324
+ extra_result if isinstance(extra_result, ToolResult) else None
325
+ )
326
+ if tool_result is None:
319
327
  context = ToolContext(cwd=cwd, web_searcher=web_searcher)
320
- result = await (
328
+ tool_result = await (
321
329
  tool_runner(
322
330
  tool_call.name,
323
331
  arguments,
@@ -330,27 +338,26 @@ async def run_agent_stream(
330
338
  context,
331
339
  )
332
340
  )
333
- result_content = result.content
341
+ result_content = tool_result_model_content(tool_result)
334
342
  logger.debug(
335
343
  "Tool call finished name=%s id=%s ok=%s",
336
344
  tool_call.name,
337
345
  tool_item["id"],
338
- result.ok,
346
+ tool_result.ok,
339
347
  )
340
348
  logger.log(
341
349
  TRACE_LEVEL,
342
350
  "Tool result id=%s result=%r",
343
351
  tool_item["id"],
344
- result.model_dump(),
352
+ tool_result.model_dump(),
345
353
  )
346
354
  yield AgentStreamEvent(
347
- event="tool_done" if result.ok else "tool_error",
355
+ event="tool_done" if tool_result.ok else "tool_error",
348
356
  data={
349
357
  "id": tool_item["id"],
350
- "content": result.content,
351
- "data": result.data,
352
- "status": "success" if result.ok else "failed",
353
- "title": result.title,
358
+ "result": tool_result.result,
359
+ "status": "success" if tool_result.ok else "failed",
360
+ "title": tool_result.title,
354
361
  },
355
362
  )
356
363
  conversation.append(tool_result_message(tool_call_id, result_content))
@@ -19,6 +19,18 @@ class ProviderModelsResponse(BaseModel):
19
19
  models: list[str]
20
20
 
21
21
 
22
+ class ProviderModelsFailureResponse(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ code: Literal[
26
+ "connection_failed",
27
+ "access_denied",
28
+ "rate_limited",
29
+ "provider_unavailable",
30
+ "request_failed",
31
+ ]
32
+
33
+
22
34
  class WorkspaceMessagesRequest(BaseModel):
23
35
  model_config = ConfigDict(extra="forbid")
24
36
 
@@ -35,8 +47,8 @@ class WorkspaceMessageEditRequest(BaseModel):
35
47
  class WorkspaceMessageEditResponse(BaseModel):
36
48
  model_config = ConfigDict(extra="forbid")
37
49
 
50
+ is_responding: bool = False
38
51
  messages: list[StoredMessage]
39
- run_id: str | None = None
40
52
 
41
53
 
42
54
  class WorkspaceRespondRequest(BaseModel):
@@ -46,16 +58,9 @@ class WorkspaceRespondRequest(BaseModel):
46
58
  message_id: str | None = None
47
59
 
48
60
 
49
- class WorkspaceRunResponse(BaseModel):
50
- model_config = ConfigDict(extra="forbid")
51
-
52
- run_id: str
53
-
54
-
55
61
  class WorkspaceClearResponse(BaseModel):
56
62
  model_config = ConfigDict(extra="forbid")
57
63
 
58
- active_run_id: str | None = None
59
64
  messages: list[StoredMessage]
60
65
  usage_info: TokenUsageInfo | None = None
61
66
 
@@ -85,6 +85,20 @@ class LLMStreamError(RuntimeError):
85
85
  pass
86
86
 
87
87
 
88
+ class ProviderModelFetchFailure(StrEnum):
89
+ CONNECTION_FAILED = "connection_failed"
90
+ ACCESS_DENIED = "access_denied"
91
+ RATE_LIMITED = "rate_limited"
92
+ PROVIDER_UNAVAILABLE = "provider_unavailable"
93
+ REQUEST_FAILED = "request_failed"
94
+
95
+
96
+ class ProviderModelFetchError(RuntimeError):
97
+ def __init__(self, failure: ProviderModelFetchFailure) -> None:
98
+ super().__init__(failure.value)
99
+ self.failure = failure
100
+
101
+
88
102
  async def wait_before_llm_retry(attempt_number: int) -> None:
89
103
  await asyncio.sleep(LLM_RETRY_BASE_DELAY_SECONDS * attempt_number)
90
104
 
@@ -248,6 +262,31 @@ def unique_model_names(provider: ProviderFormat, models: Sequence[str]) -> list[
248
262
  return normalized_models
249
263
 
250
264
 
265
+ def classify_provider_model_fetch_failure(
266
+ exception: Exception,
267
+ ) -> ProviderModelFetchFailure:
268
+ try:
269
+ import litellm
270
+ except Exception:
271
+ return ProviderModelFetchFailure.REQUEST_FAILED
272
+
273
+ if isinstance(exception, (litellm.APIConnectionError, litellm.Timeout)):
274
+ return ProviderModelFetchFailure.CONNECTION_FAILED
275
+ if isinstance(
276
+ exception, (litellm.AuthenticationError, litellm.PermissionDeniedError)
277
+ ):
278
+ return ProviderModelFetchFailure.ACCESS_DENIED
279
+ if isinstance(exception, litellm.RateLimitError):
280
+ return ProviderModelFetchFailure.RATE_LIMITED
281
+ if isinstance(
282
+ exception, (litellm.ServiceUnavailableError, litellm.InternalServerError)
283
+ ):
284
+ return ProviderModelFetchFailure.PROVIDER_UNAVAILABLE
285
+ if isinstance(exception, (ConnectionError, TimeoutError)):
286
+ return ProviderModelFetchFailure.CONNECTION_FAILED
287
+ return ProviderModelFetchFailure.REQUEST_FAILED
288
+
289
+
251
290
  def list_provider_models(
252
291
  *,
253
292
  provider: ProviderFormat,
@@ -261,12 +300,17 @@ def list_provider_models(
261
300
  configure_litellm_logging()
262
301
  model_lister = get_valid_models
263
302
 
264
- models = model_lister(
265
- api_base=normalize_provider_base_url(provider, base_url),
266
- api_key=secret_reference,
267
- check_provider_endpoint=True,
268
- custom_llm_provider=provider_litellm_name(provider),
269
- )
303
+ try:
304
+ models = model_lister(
305
+ api_base=normalize_provider_base_url(provider, base_url),
306
+ api_key=secret_reference,
307
+ check_provider_endpoint=True,
308
+ custom_llm_provider=provider_litellm_name(provider),
309
+ )
310
+ except Exception as exc:
311
+ raise ProviderModelFetchError(
312
+ classify_provider_model_fetch_failure(exc)
313
+ ) from exc
270
314
  return unique_model_names(provider, models)
271
315
 
272
316
 
@@ -662,11 +662,12 @@ class McpManager:
662
662
  content = mcp_result_content(result)
663
663
  server_name = self._server_names.get(server_id, server_id)
664
664
  return ToolResult(
665
- content=content,
666
- data={
665
+ result={
666
+ "type": "mcp",
667
+ "output": content,
667
668
  "server": server_name,
668
669
  "tool": tool_name,
669
- "result": result,
670
+ "raw_result": result,
670
671
  },
671
672
  ok=not mcp_result_is_error(result),
672
673
  title=f"Calling {server_name}.{tool_name}",
@@ -16,10 +16,13 @@ from flowent.shell import shell_invocation
16
16
  from flowent.tools import (
17
17
  ToolContext,
18
18
  ToolResult,
19
+ command_tool_result,
19
20
  number_argument,
20
21
  patch_title_from_result,
21
22
  run_tool_async,
23
+ text_tool_result,
22
24
  tool_failure_content,
25
+ tool_result_model_content,
23
26
  )
24
27
 
25
28
  SANDBOX_WITH_ADDITIONAL_PERMISSIONS = "with_additional_permissions"
@@ -66,7 +69,7 @@ def validate_additional_permissions(arguments: dict[str, object]) -> ToolResult
66
69
  return None
67
70
  if sandbox_permissions != SANDBOX_WITH_ADDITIONAL_PERMISSIONS:
68
71
  return ToolResult(
69
- content=(
72
+ result=text_tool_result(
70
73
  "additional_permissions requires sandbox_permissions to be "
71
74
  "with_additional_permissions."
72
75
  ),
@@ -130,8 +133,10 @@ async def review_missing_write_paths(
130
133
  return (
131
134
  effective_paths,
132
135
  ToolResult(
133
- content=approval_denial_content(decision),
134
- data=review_data,
136
+ result=text_tool_result(
137
+ approval_denial_content(decision),
138
+ **review_data,
139
+ ),
135
140
  ok=False,
136
141
  title="Denied by reviewer",
137
142
  ),
@@ -196,7 +201,7 @@ async def run_shell_command_with_permissions(
196
201
  arguments, context, effective_paths
197
202
  )
198
203
  if approval_data is not None:
199
- result = tool_result_with_data(result, approval_data)
204
+ result = tool_result_with_fields(result, approval_data)
200
205
  if result.ok or not is_likely_sandbox_denied_result(result):
201
206
  return result
202
207
  review_request = ApprovalReviewRequest(
@@ -210,13 +215,16 @@ async def run_shell_command_with_permissions(
210
215
  review_data = approval_result_data(review_request, decision)
211
216
  if decision.decision == "denied":
212
217
  return ToolResult(
213
- content=approval_denial_content(decision),
214
- data={**result.data, **review_data},
218
+ result=text_tool_result(
219
+ approval_denial_content(decision),
220
+ previous_result=result.result,
221
+ **review_data,
222
+ ),
215
223
  ok=False,
216
224
  title="Denied by reviewer",
217
225
  )
218
226
  retry_result = await shell_command_without_sandbox(arguments, context)
219
- return tool_result_with_data(retry_result, review_data)
227
+ return tool_result_with_fields(retry_result, review_data)
220
228
 
221
229
 
222
230
  async def run_apply_patch_with_permissions(
@@ -244,7 +252,7 @@ async def run_apply_patch_with_permissions(
244
252
 
245
253
  result = await apply_patch_with_writable_paths(arguments, context, effective_paths)
246
254
  if approval_data is not None:
247
- result = tool_result_with_data(result, approval_data)
255
+ result = tool_result_with_fields(result, approval_data)
248
256
  return result
249
257
 
250
258
 
@@ -268,18 +276,23 @@ async def apply_patch_with_writable_paths(
268
276
  input_text=patch,
269
277
  )
270
278
  except SandboxError as error:
271
- return ToolResult(content=str(error), ok=False, title="Edit failed")
279
+ return ToolResult(
280
+ result=text_tool_result(str(error)), ok=False, title="Edit failed"
281
+ )
272
282
 
273
283
  if result.exit_code != 0:
274
284
  return ToolResult(
275
- content=tool_failure_content(result),
285
+ result=text_tool_result(tool_failure_content(result)),
276
286
  ok=False,
277
287
  title="Edit failed",
278
288
  )
279
289
  data = json.loads(result.stdout or "{}")
280
290
  return ToolResult(
281
- content=result.stdout,
282
- data=data if isinstance(data, dict) else {},
291
+ result={
292
+ "type": "patch",
293
+ "output": result.stdout,
294
+ **(data if isinstance(data, dict) else {}),
295
+ },
283
296
  title=patch_title_from_result(data),
284
297
  )
285
298
 
@@ -297,27 +310,25 @@ async def shell_command_with_writable_paths(
297
310
  writable_roots=writable_paths,
298
311
  ).run_async(invocation.args, env=invocation.env, timeout_seconds=timeout_seconds)
299
312
  ok = result.exit_code == 0
300
- content = result.stdout or result.stderr
301
313
  return ToolResult(
302
- content=content,
303
- data={
304
- "command": command,
305
- "exit_code": result.exit_code,
306
- "stderr": result.stderr,
307
- "stdout": result.stdout,
308
- },
314
+ result=command_tool_result(
315
+ command=command,
316
+ exit_code=result.exit_code,
317
+ stderr=result.stderr,
318
+ stdout=result.stdout,
319
+ ),
309
320
  ok=ok,
310
321
  title=f"Ran {command}",
311
322
  )
312
323
 
313
324
 
314
325
  def is_likely_sandbox_denied_result(result: ToolResult) -> bool:
315
- data = result.data
316
- exit_code = int_result_field(data.get("exit_code"))
326
+ payload = result.result
327
+ exit_code = int_result_field(payload.get("exit_code"))
317
328
  if exit_code == 0:
318
329
  return False
319
330
  output = "\n".join(
320
- str(data.get(name, "") or "") for name in ["stderr", "stdout"]
331
+ str(payload.get(name, "") or "") for name in ["stderr", "stdout", "output"]
321
332
  ).lower()
322
333
  return any(
323
334
  keyword in output
@@ -345,13 +356,17 @@ def int_result_field(value: object) -> int:
345
356
 
346
357
 
347
358
  def tool_failure_text(result: ToolResult) -> str:
348
- stderr = str(result.data.get("stderr", "") or "").strip()
349
- stdout = str(result.data.get("stdout", "") or "").strip()
350
- content = result.content.strip()
359
+ payload = result.result
360
+ stderr = str(payload.get("stderr", "") or "").strip()
361
+ stdout = str(payload.get("stdout", "") or "").strip()
351
362
  parts: list[str] = []
352
- for part in [stderr, stdout, content]:
363
+ for part in [stderr, stdout]:
353
364
  if part and part not in parts:
354
365
  parts.append(part)
366
+ if not parts:
367
+ content = tool_result_model_content(result).strip()
368
+ if content:
369
+ parts.append(content)
355
370
  return "\n".join(parts)
356
371
 
357
372
 
@@ -366,15 +381,13 @@ async def shell_command_without_sandbox(
366
381
  invocation.args, env=invocation.env, timeout_seconds=timeout_seconds
367
382
  )
368
383
  ok = result.exit_code == 0
369
- content = result.stdout or result.stderr
370
384
  return ToolResult(
371
- content=content,
372
- data={
373
- "command": command,
374
- "exit_code": result.exit_code,
375
- "stderr": result.stderr,
376
- "stdout": result.stdout,
377
- },
385
+ result=command_tool_result(
386
+ command=command,
387
+ exit_code=result.exit_code,
388
+ stderr=result.stderr,
389
+ stdout=result.stdout,
390
+ ),
378
391
  ok=ok,
379
392
  title=f"Ran {command}",
380
393
  )
@@ -403,7 +416,7 @@ def approval_result_data(
403
416
  }
404
417
 
405
418
 
406
- def tool_result_with_data(
407
- result: ToolResult, extra_data: dict[str, object]
419
+ def tool_result_with_fields(
420
+ result: ToolResult, extra_fields: dict[str, object]
408
421
  ) -> ToolResult:
409
- return result.model_copy(update={"data": {**result.data, **extra_data}})
422
+ return result.model_copy(update={"result": {**result.result, **extra_fields}})
@@ -1,9 +1,25 @@
1
- from fastapi import FastAPI
1
+ from fastapi import FastAPI, HTTPException, status
2
2
 
3
- from flowent.api_models import ProviderModelsRequest, ProviderModelsResponse
4
- from flowent.llm import list_provider_models
3
+ from flowent.api_models import (
4
+ ProviderModelsFailureResponse,
5
+ ProviderModelsRequest,
6
+ ProviderModelsResponse,
7
+ )
8
+ from flowent.llm import (
9
+ ProviderModelFetchError,
10
+ ProviderModelFetchFailure,
11
+ list_provider_models,
12
+ )
5
13
  from flowent.storage import StateStore, StoredProvider, StoredSettings
6
14
 
15
+ PROVIDER_MODEL_FAILURE_STATUS: dict[ProviderModelFetchFailure, int] = {
16
+ ProviderModelFetchFailure.CONNECTION_FAILED: status.HTTP_502_BAD_GATEWAY,
17
+ ProviderModelFetchFailure.ACCESS_DENIED: status.HTTP_403_FORBIDDEN,
18
+ ProviderModelFetchFailure.RATE_LIMITED: status.HTTP_429_TOO_MANY_REQUESTS,
19
+ ProviderModelFetchFailure.PROVIDER_UNAVAILABLE: status.HTTP_503_SERVICE_UNAVAILABLE,
20
+ ProviderModelFetchFailure.REQUEST_FAILED: status.HTTP_400_BAD_REQUEST,
21
+ }
22
+
7
23
 
8
24
  def register_provider_routes(app: FastAPI, *, store: StateStore) -> None:
9
25
  @app.post("/api/providers")
@@ -17,13 +33,20 @@ def register_provider_routes(app: FastAPI, *, store: StateStore) -> None:
17
33
 
18
34
  @app.post("/api/providers/models")
19
35
  async def provider_models(request: ProviderModelsRequest) -> ProviderModelsResponse:
20
- return ProviderModelsResponse(
21
- models=list_provider_models(
22
- base_url=request.base_url,
23
- provider=request.provider,
24
- secret_reference=request.secret_reference,
25
- ),
26
- )
36
+ try:
37
+ return ProviderModelsResponse(
38
+ models=list_provider_models(
39
+ base_url=request.base_url,
40
+ provider=request.provider,
41
+ secret_reference=request.secret_reference,
42
+ ),
43
+ )
44
+ except ProviderModelFetchError as exc:
45
+ detail = ProviderModelsFailureResponse(code=exc.failure.value)
46
+ raise HTTPException(
47
+ detail=detail.model_dump(),
48
+ status_code=PROVIDER_MODEL_FAILURE_STATUS[exc.failure],
49
+ ) from exc
27
50
 
28
51
  @app.put("/api/settings")
29
52
  async def save_settings(settings: StoredSettings) -> StoredSettings:
@@ -28,14 +28,13 @@ def register_system_routes(
28
28
  @app.get("/api/state")
29
29
  async def app_state() -> StoredState:
30
30
  state = state_with_current_model_context_window(store.read_state())
31
- active_run = runtime.active_run()
31
+ active_response = runtime.current_response()
32
32
  update: dict[str, object] = {
33
- "active_run_event_index": active_run.latest_event_index
34
- if active_run
33
+ "is_responding": active_response is not None
34
+ and not active_response.is_done,
35
+ "response_event_index": active_response.latest_event_index
36
+ if active_response
35
37
  else 0,
36
- "active_run_id": active_run.id
37
- if active_run and not active_run.is_done
38
- else None,
39
38
  "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
40
39
  "skills": discover_skills(cwd, store),
41
40
  }
@@ -9,7 +9,6 @@ from flowent.api_models import (
9
9
  WorkspaceMessageEditResponse,
10
10
  WorkspaceMessagesRequest,
11
11
  WorkspaceRespondRequest,
12
- WorkspaceRunResponse,
13
12
  )
14
13
  from flowent.logging import TRACE_LEVEL
15
14
  from flowent.storage import StateStore
@@ -42,45 +41,54 @@ def register_workspace_routes(
42
41
  len(request.content),
43
42
  )
44
43
  logger.log(TRACE_LEVEL, "Workspace edited user content=%r", request.content)
45
- messages, run = runtime.edit_message(
44
+ messages, response = runtime.edit_message(
46
45
  message_id,
47
46
  action=request.action,
48
47
  content=request.content,
49
48
  )
50
49
  return WorkspaceMessageEditResponse(
50
+ is_responding=response is not None,
51
+ messages=messages,
52
+ )
53
+
54
+ @app.post("/api/workspace/messages/{message_id}/errors/{error_id}/retry")
55
+ async def retry_workspace_error(
56
+ message_id: str,
57
+ error_id: str,
58
+ ) -> WorkspaceMessageEditResponse:
59
+ logger.info(
60
+ "Workspace error retry requested message_id=%s error_id=%s",
61
+ message_id,
62
+ error_id,
63
+ )
64
+ messages, response = runtime.retry_error(
65
+ message_id,
66
+ error_id=error_id,
67
+ )
68
+ return WorkspaceMessageEditResponse(
69
+ is_responding=response is not None,
51
70
  messages=messages,
52
- run_id=run.id if run else None,
53
71
  )
54
72
 
55
73
  @app.post("/api/workspace/clear")
56
74
  async def clear_workspace() -> WorkspaceClearResponse:
57
75
  messages = runtime.clear()
58
- await runtime.notify_cleared_runs()
76
+ await runtime.notify_cleared_response()
59
77
  return WorkspaceClearResponse(messages=messages)
60
78
 
61
- @app.post("/api/workspace/runs")
62
- async def start_workspace_run(
63
- request: WorkspaceRespondRequest,
64
- ) -> WorkspaceRunResponse:
65
- logger.info("Workspace run requested content_length=%s", len(request.content))
66
- logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
67
- run = runtime.create_run(request.content, message_id=request.message_id)
68
- return WorkspaceRunResponse(run_id=run.id)
69
-
70
- @app.get("/api/workspace/runs/{run_id}/stream")
71
- async def stream_workspace_run(
72
- run_id: str,
79
+ @app.get("/api/workspace/stream")
80
+ async def stream_workspace_response(
73
81
  after: int = Query(default=0, ge=0),
74
82
  ) -> StreamingResponse:
75
- run = runtime.run_by_id(run_id)
83
+ response = runtime.stream_current_response()
76
84
  return StreamingResponse(
77
- runtime.run_stream(run, after),
85
+ runtime.response_stream(response, after),
78
86
  media_type="text/event-stream",
79
87
  )
80
88
 
81
- @app.post("/api/workspace/runs/{run_id}/stop")
82
- async def stop_workspace_run(run_id: str) -> dict[str, bool]:
83
- runtime.stop_run(run_id)
89
+ @app.post("/api/workspace/stop")
90
+ async def stop_workspace_response() -> dict[str, bool]:
91
+ runtime.stop_response()
84
92
  return {"ok": True}
85
93
 
86
94
  @app.post("/api/workspace/compact", response_class=StreamingResponse)
@@ -98,8 +106,10 @@ def register_workspace_routes(
98
106
  "Workspace response requested content_length=%s", len(request.content)
99
107
  )
100
108
  logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
101
- run = runtime.create_run(request.content, message_id=request.message_id)
109
+ response = runtime.start_response(
110
+ request.content, message_id=request.message_id
111
+ )
102
112
  return StreamingResponse(
103
- runtime.run_stream(run, include_snapshots=False),
113
+ runtime.response_stream(response, include_snapshots=False),
104
114
  media_type="text/event-stream",
105
115
  )
@@ -151,8 +151,7 @@ class StoredToolItem(BaseModel):
151
151
  status: str
152
152
  title: str
153
153
  arguments: dict[str, object] | None = None
154
- content: str | None = None
155
- data: dict[str, object] | None = None
154
+ result: dict[str, object] | None = None
156
155
 
157
156
 
158
157
  class StoredThinkingOutputItem(BaseModel):
@@ -217,6 +216,7 @@ class StoredMessage(BaseModel):
217
216
  status: str = Field(
218
217
  default="completed", exclude_if=lambda value: value == "completed"
219
218
  )
219
+ summary: str = Field(default="", exclude_if=lambda value: value == "")
220
220
  thinking: str = Field(default="", exclude_if=lambda value: value == "")
221
221
  tools: list[StoredToolItem] = Field(default_factory=list)
222
222
  usage_info: TokenUsageInfo | None = Field(
@@ -241,8 +241,8 @@ class StoredCompactionCheckpoint(BaseModel):
241
241
  class StoredState(BaseModel):
242
242
  model_config = ConfigDict(extra="forbid")
243
243
 
244
- active_run_event_index: int = 0
245
- active_run_id: str | None = None
244
+ is_responding: bool = False
245
+ response_event_index: int = 0
246
246
  is_compacting: bool = False
247
247
  mcp_servers: list[StoredMcpServer]
248
248
  messages: list[StoredMessage]