flowent 0.2.4 → 0.3.0
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/README.md +3 -3
- package/backend/README.md +3 -3
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +1 -1
- package/backend/src/flowent/api_models.py +103 -0
- package/backend/src/flowent/app.py +151 -0
- package/backend/src/flowent/cli.py +13 -4
- package/backend/src/flowent/compact.py +34 -13
- package/backend/src/flowent/llm.py +2 -0
- package/backend/src/flowent/main.py +18 -1994
- package/backend/src/flowent/mcp.py +100 -2
- package/backend/src/flowent/network.py +5 -0
- package/backend/src/flowent/provider_connections.py +42 -0
- package/backend/src/flowent/routes/__init__.py +0 -0
- package/backend/src/flowent/routes/integrations.py +105 -0
- package/backend/src/flowent/routes/permissions.py +36 -0
- package/backend/src/flowent/routes/providers.py +30 -0
- package/backend/src/flowent/routes/system.py +49 -0
- package/backend/src/flowent/routes/workflow_routes.py +63 -0
- package/backend/src/flowent/routes/workspace.py +105 -0
- package/backend/src/flowent/state/__init__.py +53 -0
- package/backend/src/flowent/state/models.py +257 -0
- package/backend/src/flowent/state/schema.py +186 -0
- package/backend/src/flowent/state/store.py +1013 -0
- package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
- package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +52 -1318
- package/backend/src/flowent/system_tools.py +25 -0
- package/backend/src/flowent/tools.py +4 -2
- package/backend/src/flowent/usage.py +9 -4
- package/backend/src/flowent/workflows.py +282 -0
- package/backend/src/flowent/workspace/__init__.py +0 -0
- package/backend/src/flowent/workspace/context.py +249 -0
- package/backend/src/flowent/workspace/events.py +180 -0
- package/backend/src/flowent/workspace/output.py +274 -0
- package/backend/src/flowent/workspace/runtime.py +1041 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
- package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
- package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
- package/dist/frontend/assets/index-BH30iLzb.css +0 -2
- package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from flowent.storage import StoredMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class WorkspaceRun:
|
|
13
|
+
condition: asyncio.Condition
|
|
14
|
+
active_output: Literal["text", "thinking"] | None = None
|
|
15
|
+
discard_on_cancel: bool = False
|
|
16
|
+
events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
|
|
17
|
+
generation: int = 0
|
|
18
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
19
|
+
is_done: bool = False
|
|
20
|
+
latest_snapshot: StoredMessage | None = None
|
|
21
|
+
task: asyncio.Task[None] | None = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def latest_event_index(self) -> int:
|
|
25
|
+
return self.events[-1][0] if self.events else 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def stream_event(
|
|
29
|
+
event: str, data: dict[str, object], event_id: int | None = None
|
|
30
|
+
) -> str:
|
|
31
|
+
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
|
32
|
+
return f"{id_line}event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def stream_message_data(
|
|
36
|
+
message: StoredMessage, active_output: Literal["text", "thinking"] | None = None
|
|
37
|
+
) -> dict[str, object]:
|
|
38
|
+
data = {**message.model_dump(), "status": message.status}
|
|
39
|
+
if active_output is not None:
|
|
40
|
+
data["active_output"] = active_output
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def append_or_replace_message(
|
|
45
|
+
messages: list[StoredMessage], message: StoredMessage
|
|
46
|
+
) -> list[StoredMessage]:
|
|
47
|
+
return [
|
|
48
|
+
*(current for current in messages if current.id != message.id),
|
|
49
|
+
message,
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_snapshot_data_at(
|
|
54
|
+
run: WorkspaceRun, event_index: int
|
|
55
|
+
) -> dict[str, object] | None:
|
|
56
|
+
snapshot_event_index = 0
|
|
57
|
+
snapshot: dict[str, object] | None = None
|
|
58
|
+
for current_event_index, event, data in run.events:
|
|
59
|
+
if current_event_index > event_index:
|
|
60
|
+
break
|
|
61
|
+
if event != "snapshot":
|
|
62
|
+
if event == "start" and snapshot is None:
|
|
63
|
+
assistant_id = data.get("id")
|
|
64
|
+
if isinstance(assistant_id, str):
|
|
65
|
+
snapshot_event_index = current_event_index
|
|
66
|
+
snapshot = {
|
|
67
|
+
"author": "assistant",
|
|
68
|
+
"content": "",
|
|
69
|
+
"groups": [],
|
|
70
|
+
"id": assistant_id,
|
|
71
|
+
"status": "running",
|
|
72
|
+
"tools": [],
|
|
73
|
+
}
|
|
74
|
+
continue
|
|
75
|
+
message = data.get("message")
|
|
76
|
+
if isinstance(message, dict):
|
|
77
|
+
snapshot_event_index = current_event_index
|
|
78
|
+
snapshot = copy.deepcopy(message)
|
|
79
|
+
if snapshot is None:
|
|
80
|
+
return None
|
|
81
|
+
for current_event_index, event, data in run.events:
|
|
82
|
+
if current_event_index <= snapshot_event_index:
|
|
83
|
+
continue
|
|
84
|
+
if current_event_index > event_index:
|
|
85
|
+
break
|
|
86
|
+
apply_stream_event_to_snapshot(snapshot, event, data)
|
|
87
|
+
return snapshot
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def apply_stream_event_to_snapshot(
|
|
91
|
+
snapshot: dict[str, object], event: str, data: dict[str, object]
|
|
92
|
+
) -> None:
|
|
93
|
+
if event == "output_start":
|
|
94
|
+
snapshot.pop("active_output", None)
|
|
95
|
+
index = data.get("index")
|
|
96
|
+
if isinstance(index, int):
|
|
97
|
+
append_snapshot_group(snapshot, index)
|
|
98
|
+
if event == "delta":
|
|
99
|
+
append_snapshot_text(snapshot, str(data.get("content") or ""))
|
|
100
|
+
if event == "thinking_delta":
|
|
101
|
+
append_snapshot_thinking(snapshot, str(data.get("content") or ""))
|
|
102
|
+
if event == "output_done":
|
|
103
|
+
snapshot.pop("active_output", None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def snapshot_groups(snapshot: dict[str, object]) -> list[dict[str, object]]:
|
|
107
|
+
groups = snapshot.get("groups")
|
|
108
|
+
if not isinstance(groups, list):
|
|
109
|
+
groups = []
|
|
110
|
+
snapshot["groups"] = groups
|
|
111
|
+
return groups
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def append_snapshot_group(
|
|
115
|
+
snapshot: dict[str, object], index: int | None = None
|
|
116
|
+
) -> None:
|
|
117
|
+
groups = snapshot_groups(snapshot)
|
|
118
|
+
assistant_id = str(snapshot.get("id") or "assistant")
|
|
119
|
+
group_index = index if index is not None else len(groups) + 1
|
|
120
|
+
group_id = f"{assistant_id}-group-{group_index}"
|
|
121
|
+
if groups and groups[-1].get("id") == group_id:
|
|
122
|
+
return
|
|
123
|
+
groups.append({"id": group_id, "items": []})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def append_snapshot_text(snapshot: dict[str, object], content: str) -> None:
|
|
127
|
+
if not content:
|
|
128
|
+
return
|
|
129
|
+
snapshot["active_output"] = "text"
|
|
130
|
+
snapshot["content"] = f"{snapshot.get('content') or ''}{content}"
|
|
131
|
+
append_snapshot_item_content(snapshot, content, "text")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def append_snapshot_thinking(snapshot: dict[str, object], content: str) -> None:
|
|
135
|
+
if not content:
|
|
136
|
+
return
|
|
137
|
+
snapshot["active_output"] = "thinking"
|
|
138
|
+
snapshot["thinking"] = f"{snapshot.get('thinking') or ''}{content}"
|
|
139
|
+
append_snapshot_item_content(snapshot, content, "thinking")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def append_snapshot_item_content(
|
|
143
|
+
snapshot: dict[str, object], content: str, item_type: Literal["text", "thinking"]
|
|
144
|
+
) -> None:
|
|
145
|
+
groups = snapshot_groups(snapshot)
|
|
146
|
+
if not groups:
|
|
147
|
+
append_snapshot_group(snapshot)
|
|
148
|
+
group = groups[-1]
|
|
149
|
+
items = group.get("items")
|
|
150
|
+
if not isinstance(items, list):
|
|
151
|
+
items = []
|
|
152
|
+
group["items"] = items
|
|
153
|
+
item = next(
|
|
154
|
+
(
|
|
155
|
+
current
|
|
156
|
+
for current in reversed(items)
|
|
157
|
+
if isinstance(current, dict) and current.get("type") == item_type
|
|
158
|
+
),
|
|
159
|
+
None,
|
|
160
|
+
)
|
|
161
|
+
if item is None:
|
|
162
|
+
assistant_id = str(snapshot.get("id") or "assistant")
|
|
163
|
+
snapshot_item_count = 0
|
|
164
|
+
for current_group in groups:
|
|
165
|
+
current_items = current_group.get("items")
|
|
166
|
+
if not isinstance(current_items, list):
|
|
167
|
+
continue
|
|
168
|
+
snapshot_item_count += sum(
|
|
169
|
+
1
|
|
170
|
+
for current_item in current_items
|
|
171
|
+
if isinstance(current_item, dict)
|
|
172
|
+
and current_item.get("type") == item_type
|
|
173
|
+
)
|
|
174
|
+
item = {
|
|
175
|
+
"content": "",
|
|
176
|
+
"id": f"{assistant_id}-{item_type}-{snapshot_item_count + 1}",
|
|
177
|
+
"type": item_type,
|
|
178
|
+
}
|
|
179
|
+
items.append(item)
|
|
180
|
+
item["content"] = f"{item.get('content') or ''}{content}"
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from flowent.approval import ApprovalTranscriptEntry
|
|
5
|
+
from flowent.logging import redact_diagnostic_value
|
|
6
|
+
from flowent.storage import (
|
|
7
|
+
StoredAssistantOutputGroup,
|
|
8
|
+
StoredErrorOutputItem,
|
|
9
|
+
StoredMessage,
|
|
10
|
+
StoredTextOutputItem,
|
|
11
|
+
StoredThinkingOutputItem,
|
|
12
|
+
StoredToolItem,
|
|
13
|
+
StoredToolOutputItem,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
|
|
17
|
+
APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
|
|
18
|
+
USER_VISIBLE_RUN_ERROR_TITLE = "Request failed"
|
|
19
|
+
USER_VISIBLE_RUN_ERROR_MESSAGE = "Check the model connection settings and try again."
|
|
20
|
+
USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE = "Context could not be optimized."
|
|
21
|
+
EMPTY_MODEL_RESPONSE_DETAIL = "The model did not return a response."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user_visible_run_error_message(detail: str) -> str:
|
|
25
|
+
if detail.strip() == USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE:
|
|
26
|
+
return USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE
|
|
27
|
+
return USER_VISIBLE_RUN_ERROR_MESSAGE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_error_output_item(
|
|
31
|
+
assistant_id: str,
|
|
32
|
+
detail: str,
|
|
33
|
+
index: int = 1,
|
|
34
|
+
) -> StoredErrorOutputItem:
|
|
35
|
+
redacted_detail = redact_diagnostic_value(detail.strip())
|
|
36
|
+
message = user_visible_run_error_message(redacted_detail)
|
|
37
|
+
return StoredErrorOutputItem(
|
|
38
|
+
detail="" if redacted_detail == message else redacted_detail,
|
|
39
|
+
id=f"{assistant_id}-error-{index}",
|
|
40
|
+
message=message,
|
|
41
|
+
title=USER_VISIBLE_RUN_ERROR_TITLE,
|
|
42
|
+
type="error",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_error_event_data(error: StoredErrorOutputItem) -> dict[str, object]:
|
|
47
|
+
return {
|
|
48
|
+
"error": error.model_dump(exclude_none=True),
|
|
49
|
+
"message": error.message,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def message_error_items(message: StoredMessage) -> list[StoredErrorOutputItem]:
|
|
54
|
+
return [
|
|
55
|
+
item for group in message.groups for item in group.items if item.type == "error"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def error_context_summary(error: StoredErrorOutputItem) -> str:
|
|
60
|
+
parts = [f"Previous response failed: {error.title}.", error.message]
|
|
61
|
+
if error.detail and error.detail != error.message:
|
|
62
|
+
parts.append(f"Detail: {error.detail}")
|
|
63
|
+
return " ".join(part.strip() for part in parts if part.strip())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def approval_transcript_text(content: str | None) -> str:
|
|
67
|
+
text = (content or "").strip()
|
|
68
|
+
if len(text) <= APPROVAL_TRANSCRIPT_TEXT_LIMIT:
|
|
69
|
+
return text
|
|
70
|
+
return f"{text[:APPROVAL_TRANSCRIPT_TEXT_LIMIT]}\n[truncated]"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def approval_transcript(
|
|
74
|
+
messages: Sequence[StoredMessage],
|
|
75
|
+
) -> list[ApprovalTranscriptEntry]:
|
|
76
|
+
entries: list[ApprovalTranscriptEntry] = []
|
|
77
|
+
for message in messages[-APPROVAL_TRANSCRIPT_MESSAGE_LIMIT:]:
|
|
78
|
+
if message.author in ("user", "assistant"):
|
|
79
|
+
role: Literal["user", "assistant"] = (
|
|
80
|
+
"user" if message.author == "user" else "assistant"
|
|
81
|
+
)
|
|
82
|
+
content = approval_transcript_text(message.content)
|
|
83
|
+
if content:
|
|
84
|
+
entries.append(ApprovalTranscriptEntry(role=role, content=content))
|
|
85
|
+
for tool in message.tools:
|
|
86
|
+
tool_content = approval_transcript_text(tool.content)
|
|
87
|
+
if tool_content:
|
|
88
|
+
entries.append(
|
|
89
|
+
ApprovalTranscriptEntry(
|
|
90
|
+
role="tool",
|
|
91
|
+
content=tool_content,
|
|
92
|
+
name=tool.name,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return entries
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AssistantOutputBuilder:
|
|
99
|
+
def __init__(self, assistant_id: str = "") -> None:
|
|
100
|
+
self.assistant_id = assistant_id
|
|
101
|
+
self.content = ""
|
|
102
|
+
self.groups: list[StoredAssistantOutputGroup] = []
|
|
103
|
+
self.text_item_index = 0
|
|
104
|
+
self.text_item_id = ""
|
|
105
|
+
self.thinking = ""
|
|
106
|
+
self.thinking_item_index = 0
|
|
107
|
+
self.thinking_item_id = ""
|
|
108
|
+
self.error_item_index = 0
|
|
109
|
+
self.tools: dict[str, StoredToolItem] = {}
|
|
110
|
+
|
|
111
|
+
def set_assistant_id(self, assistant_id: str) -> None:
|
|
112
|
+
self.assistant_id = assistant_id
|
|
113
|
+
|
|
114
|
+
def start_group(self, index: int) -> None:
|
|
115
|
+
group_id = f"{self.assistant_id or 'assistant'}-group-{index}"
|
|
116
|
+
if self.groups and self.groups[-1].id == group_id:
|
|
117
|
+
return
|
|
118
|
+
self.text_item_id = ""
|
|
119
|
+
self.thinking_item_id = ""
|
|
120
|
+
self.groups.append(StoredAssistantOutputGroup(id=group_id, items=[]))
|
|
121
|
+
|
|
122
|
+
def append_text(self, content: str) -> None:
|
|
123
|
+
if not content:
|
|
124
|
+
return
|
|
125
|
+
self._ensure_group()
|
|
126
|
+
if not self.text_item_id:
|
|
127
|
+
self.text_item_index += 1
|
|
128
|
+
self.text_item_id = f"{self.assistant_id}-text-{self.text_item_index}"
|
|
129
|
+
self._append_current_item(
|
|
130
|
+
StoredTextOutputItem(content="", id=self.text_item_id, type="text")
|
|
131
|
+
)
|
|
132
|
+
self.content += content
|
|
133
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
134
|
+
update={
|
|
135
|
+
"items": [
|
|
136
|
+
item.model_copy(update={"content": item.content + content})
|
|
137
|
+
if item.type == "text" and item.id == self.text_item_id
|
|
138
|
+
else item
|
|
139
|
+
for item in self.groups[-1].items
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def append_thinking(self, content: str) -> None:
|
|
145
|
+
if not content:
|
|
146
|
+
return
|
|
147
|
+
self._ensure_group()
|
|
148
|
+
if not self.thinking_item_id:
|
|
149
|
+
self.thinking_item_index += 1
|
|
150
|
+
self.thinking_item_id = (
|
|
151
|
+
f"{self.assistant_id}-thinking-{self.thinking_item_index}"
|
|
152
|
+
)
|
|
153
|
+
self._append_current_item(
|
|
154
|
+
StoredThinkingOutputItem(
|
|
155
|
+
content="", id=self.thinking_item_id, type="thinking"
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
self.thinking += content
|
|
159
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
160
|
+
update={
|
|
161
|
+
"items": [
|
|
162
|
+
item.model_copy(update={"content": item.content + content})
|
|
163
|
+
if item.type == "thinking" and item.id == self.thinking_item_id
|
|
164
|
+
else item
|
|
165
|
+
for item in self.groups[-1].items
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def start_tool(self, tool: StoredToolItem) -> None:
|
|
171
|
+
self._ensure_group()
|
|
172
|
+
self.text_item_id = ""
|
|
173
|
+
self.thinking_item_id = ""
|
|
174
|
+
self.tools[tool.id] = tool
|
|
175
|
+
self._append_current_item(
|
|
176
|
+
StoredToolOutputItem(id=f"tool-{tool.id}", tool=tool, type="tool")
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def update_tool(self, tool_id: str, data: dict[str, object]) -> None:
|
|
180
|
+
current_tool = self.tools.get(tool_id)
|
|
181
|
+
if current_tool is None:
|
|
182
|
+
return
|
|
183
|
+
updated_tool = StoredToolItem.model_validate(
|
|
184
|
+
{**current_tool.model_dump(exclude_none=True), **data}
|
|
185
|
+
)
|
|
186
|
+
self.tools[tool_id] = updated_tool
|
|
187
|
+
self.groups = [
|
|
188
|
+
group.model_copy(
|
|
189
|
+
update={
|
|
190
|
+
"items": [
|
|
191
|
+
item.model_copy(update={"tool": updated_tool})
|
|
192
|
+
if item.type == "tool" and item.tool.id == tool_id
|
|
193
|
+
else item
|
|
194
|
+
for item in group.items
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
for group in self.groups
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def append_error(self, error: StoredErrorOutputItem) -> StoredErrorOutputItem:
|
|
202
|
+
self.error_item_index += 1
|
|
203
|
+
if not error.id:
|
|
204
|
+
error = error.model_copy(
|
|
205
|
+
update={"id": f"{self.assistant_id}-error-{self.error_item_index}"}
|
|
206
|
+
)
|
|
207
|
+
error_group_id = f"{self.assistant_id}-errors"
|
|
208
|
+
if self.groups and self.groups[-1].id == error_group_id:
|
|
209
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
210
|
+
update={"items": [*self.groups[-1].items, error]}
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
self.groups.append(
|
|
214
|
+
StoredAssistantOutputGroup(id=error_group_id, items=[error])
|
|
215
|
+
)
|
|
216
|
+
return error
|
|
217
|
+
|
|
218
|
+
def has_output(self) -> bool:
|
|
219
|
+
return any(group.items for group in self.groups)
|
|
220
|
+
|
|
221
|
+
def apply_done_message(self, message: dict[str, object]) -> None:
|
|
222
|
+
final_content = str(message.get("content") or self.content)
|
|
223
|
+
final_thinking = str(message.get("thinking") or self.thinking)
|
|
224
|
+
self._append_missing_done_text(final_content)
|
|
225
|
+
self._append_missing_done_thinking(final_thinking)
|
|
226
|
+
self.content = final_content
|
|
227
|
+
self.thinking = final_thinking
|
|
228
|
+
|
|
229
|
+
def _append_missing_done_text(self, final_content: str) -> None:
|
|
230
|
+
streamed_text = "".join(
|
|
231
|
+
item.content
|
|
232
|
+
for group in self.groups
|
|
233
|
+
for item in group.items
|
|
234
|
+
if item.type == "text"
|
|
235
|
+
)
|
|
236
|
+
if not final_content or streamed_text == final_content:
|
|
237
|
+
return
|
|
238
|
+
missing_text = (
|
|
239
|
+
final_content[len(streamed_text) :]
|
|
240
|
+
if final_content.startswith(streamed_text)
|
|
241
|
+
else final_content
|
|
242
|
+
)
|
|
243
|
+
self.append_text(missing_text)
|
|
244
|
+
|
|
245
|
+
def _append_missing_done_thinking(self, final_thinking: str) -> None:
|
|
246
|
+
streamed_thinking = "".join(
|
|
247
|
+
item.content
|
|
248
|
+
for group in self.groups
|
|
249
|
+
for item in group.items
|
|
250
|
+
if item.type == "thinking"
|
|
251
|
+
)
|
|
252
|
+
if not final_thinking or streamed_thinking == final_thinking:
|
|
253
|
+
return
|
|
254
|
+
missing_thinking = (
|
|
255
|
+
final_thinking[len(streamed_thinking) :]
|
|
256
|
+
if final_thinking.startswith(streamed_thinking)
|
|
257
|
+
else final_thinking
|
|
258
|
+
)
|
|
259
|
+
self.append_thinking(missing_thinking)
|
|
260
|
+
|
|
261
|
+
def _ensure_group(self) -> None:
|
|
262
|
+
if not self.groups:
|
|
263
|
+
self.start_group(1)
|
|
264
|
+
|
|
265
|
+
def _append_current_item(
|
|
266
|
+
self,
|
|
267
|
+
item: StoredTextOutputItem
|
|
268
|
+
| StoredThinkingOutputItem
|
|
269
|
+
| StoredErrorOutputItem
|
|
270
|
+
| StoredToolOutputItem,
|
|
271
|
+
) -> None:
|
|
272
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
273
|
+
update={"items": [*self.groups[-1].items, item]}
|
|
274
|
+
)
|