flowent 0.2.3 → 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.
Files changed (49) hide show
  1. package/README.md +3 -3
  2. package/backend/README.md +3 -3
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/agent.py +1 -1
  5. package/backend/src/flowent/api_models.py +103 -0
  6. package/backend/src/flowent/app.py +151 -0
  7. package/backend/src/flowent/cli.py +13 -4
  8. package/backend/src/flowent/compact.py +34 -13
  9. package/backend/src/flowent/llm.py +6 -8
  10. package/backend/src/flowent/logging.py +7 -1
  11. package/backend/src/flowent/main.py +18 -1989
  12. package/backend/src/flowent/mcp.py +231 -44
  13. package/backend/src/flowent/network.py +5 -0
  14. package/backend/src/flowent/permissions.py +5 -1
  15. package/backend/src/flowent/provider_connections.py +42 -0
  16. package/backend/src/flowent/routes/__init__.py +0 -0
  17. package/backend/src/flowent/routes/integrations.py +105 -0
  18. package/backend/src/flowent/routes/permissions.py +36 -0
  19. package/backend/src/flowent/routes/providers.py +30 -0
  20. package/backend/src/flowent/routes/system.py +49 -0
  21. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  22. package/backend/src/flowent/routes/workspace.py +105 -0
  23. package/backend/src/flowent/sandbox.py +1 -1
  24. package/backend/src/flowent/state/__init__.py +53 -0
  25. package/backend/src/flowent/state/models.py +257 -0
  26. package/backend/src/flowent/state/schema.py +186 -0
  27. package/backend/src/flowent/state/store.py +1013 -0
  28. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
  29. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
  30. package/backend/src/flowent/static/index.html +2 -2
  31. package/backend/src/flowent/storage.py +52 -1254
  32. package/backend/src/flowent/system_tools.py +25 -0
  33. package/backend/src/flowent/tools.py +4 -2
  34. package/backend/src/flowent/usage.py +9 -4
  35. package/backend/src/flowent/workflows.py +282 -0
  36. package/backend/src/flowent/workspace/__init__.py +0 -0
  37. package/backend/src/flowent/workspace/context.py +249 -0
  38. package/backend/src/flowent/workspace/events.py +180 -0
  39. package/backend/src/flowent/workspace/output.py +274 -0
  40. package/backend/src/flowent/workspace/runtime.py +1041 -0
  41. package/backend/uv.lock +1 -1
  42. package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
  43. package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
  44. package/dist/frontend/index.html +2 -2
  45. package/package.json +1 -1
  46. package/backend/src/flowent/static/assets/index-D7t9qNrC.js +0 -82
  47. package/backend/src/flowent/static/assets/index-DufpDl8x.css +0 -2
  48. package/dist/frontend/assets/index-D7t9qNrC.js +0 -82
  49. package/dist/frontend/assets/index-DufpDl8x.css +0 -2
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+
5
+
6
+ class SystemToolError(RuntimeError):
7
+ pass
8
+
9
+
10
+ RIPGREP_INSTALL_HINT = (
11
+ "Install ripgrep and try again. Debian/Ubuntu: "
12
+ "sudo apt-get install ripgrep. Fedora: sudo dnf install ripgrep. "
13
+ "Arch: sudo pacman -S ripgrep."
14
+ )
15
+
16
+
17
+ def ripgrep_binary() -> str | None:
18
+ return shutil.which("rg")
19
+
20
+
21
+ def ensure_ripgrep_available() -> str:
22
+ rg = ripgrep_binary()
23
+ if not rg:
24
+ raise SystemToolError(f"Search is not available. {RIPGREP_INSTALL_HINT}")
25
+ return rg
@@ -13,9 +13,11 @@ from uuid import uuid4
13
13
 
14
14
  from pydantic import BaseModel, ConfigDict
15
15
 
16
+ from flowent.network import flowent_user_agent
16
17
  from flowent.patch import affected_paths
17
18
  from flowent.sandbox import SandboxError, SandboxRunner
18
19
  from flowent.shell import shell_invocation
20
+ from flowent.system_tools import ensure_ripgrep_available
19
21
 
20
22
 
21
23
  class ToolResult(BaseModel):
@@ -285,7 +287,7 @@ def grep_files(arguments: dict[str, object], context: ToolContext) -> ToolResult
285
287
  pattern = str(arguments["pattern"])
286
288
  path = resolve_tool_path(str(arguments.get("path", ".") or "."), context.cwd)
287
289
  limit = integer_argument(arguments, "limit", 100)
288
- command = ["rg", "--line-number", "--max-count", str(limit)]
290
+ command = [ensure_ripgrep_available(), "--line-number", "--max-count", str(limit)]
289
291
  include = arguments.get("include")
290
292
  if include:
291
293
  command.extend(["--glob", str(include)])
@@ -438,7 +440,7 @@ def default_web_search(query: str) -> list[dict[str, str]]:
438
440
  encoded_query = urllib.parse.urlencode({"q": query})
439
441
  request = urllib.request.Request(
440
442
  f"https://duckduckgo.com/html/?{encoded_query}",
441
- headers={"User-Agent": "Flowent/0.1"},
443
+ headers={"User-Agent": flowent_user_agent()},
442
444
  )
443
445
  with urllib.request.urlopen(request, timeout=10) as response:
444
446
  body = response.read().decode(errors="replace")
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from pydantic import BaseModel, ConfigDict, Field
6
6
 
7
7
  DEFAULT_MODEL_CONTEXT_WINDOW = 120_000
8
+ APPROX_BYTES_PER_TOKEN = 4
8
9
 
9
10
  MODEL_CONTEXT_WINDOWS: dict[str, int] = {
10
11
  "claude-3-7-sonnet-20250219": 200_000,
@@ -251,12 +252,12 @@ def estimated_token_usage_for_messages(
251
252
  *,
252
253
  output_content: str = "",
253
254
  ) -> TokenUsage:
254
- total_tokens = sum(estimate_mapping_message_tokens(message) for message in messages)
255
+ input_tokens = sum(estimate_mapping_message_tokens(message) for message in messages)
255
256
  output_tokens = approximate_token_count(output_content)
256
257
  return TokenUsage(
257
- input_tokens=max(total_tokens - output_tokens, 0),
258
+ input_tokens=input_tokens,
258
259
  output_tokens=output_tokens,
259
- total_tokens=total_tokens,
260
+ total_tokens=input_tokens + output_tokens,
260
261
  )
261
262
 
262
263
 
@@ -273,7 +274,11 @@ def estimate_mapping_message_tokens(message: Mapping[str, object]) -> int:
273
274
  def approximate_token_count(content: str) -> int:
274
275
  if not content:
275
276
  return 0
276
- return max(1, (len(content) + 3) // 4)
277
+ return max(
278
+ 1,
279
+ (len(content.encode("utf-8")) + APPROX_BYTES_PER_TOKEN - 1)
280
+ // APPROX_BYTES_PER_TOKEN,
281
+ )
277
282
 
278
283
 
279
284
  def string_content(value: object) -> str:
@@ -0,0 +1,282 @@
1
+ import json
2
+ import re
3
+ from collections import defaultdict, deque
4
+ from collections.abc import Mapping
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from flowent.llm import (
9
+ ChatMessage,
10
+ CompletionCallable,
11
+ ProviderConnection,
12
+ complete_chat,
13
+ )
14
+ from flowent.storage import (
15
+ StoredWorkflow,
16
+ StoredWorkflowDefinition,
17
+ StoredWorkflowEdge,
18
+ StoredWorkflowNode,
19
+ )
20
+
21
+
22
+ class WorkflowNodeRunResult(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ error: str = ""
26
+ id: str
27
+ output: str = ""
28
+ status: str
29
+
30
+
31
+ class WorkflowRunResponse(BaseModel):
32
+ model_config = ConfigDict(extra="forbid")
33
+
34
+ node_results: list[WorkflowNodeRunResult] = Field(default_factory=list)
35
+ outputs: dict[str, str] = Field(default_factory=dict)
36
+ status: str
37
+ workflow_id: str
38
+
39
+
40
+ PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\.output\s*\}\}")
41
+
42
+
43
+ def validate_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
44
+ validate_workflow_definition(workflow.definition)
45
+ return workflow
46
+
47
+
48
+ def validate_workflow_draft(workflow: StoredWorkflow) -> StoredWorkflow:
49
+ validate_workflow_draft_definition(workflow.definition)
50
+ return workflow
51
+
52
+
53
+ def validate_workflow_draft_definition(definition: StoredWorkflowDefinition) -> None:
54
+ node_ids = [node.id for node in definition.nodes]
55
+ if any(not node_id.strip() for node_id in node_ids):
56
+ raise ValueError("Workflow node ids must not be empty.")
57
+ if len(set(node_ids)) != len(node_ids):
58
+ raise ValueError("Workflow node ids must be unique.")
59
+
60
+ edge_ids = [edge.id for edge in definition.edges]
61
+ if any(not edge_id.strip() for edge_id in edge_ids):
62
+ raise ValueError("Workflow edge ids must not be empty.")
63
+ if len(set(edge_ids)) != len(edge_ids):
64
+ raise ValueError("Workflow edge ids must be unique.")
65
+
66
+ node_id_set = set(node_ids)
67
+ for edge in definition.edges:
68
+ if edge.source not in node_id_set or edge.target not in node_id_set:
69
+ raise ValueError("Workflow edges must connect existing nodes.")
70
+
71
+
72
+ def validate_workflow_definition(definition: StoredWorkflowDefinition) -> list[str]:
73
+ node_ids = [node.id for node in definition.nodes]
74
+ if not node_ids:
75
+ raise ValueError("Workflow needs at least one node.")
76
+ if any(not node_id.strip() for node_id in node_ids):
77
+ raise ValueError("Workflow node ids must not be empty.")
78
+ if len(set(node_ids)) != len(node_ids):
79
+ raise ValueError("Workflow node ids must be unique.")
80
+ if not any(node.type == "input" for node in definition.nodes):
81
+ raise ValueError("Workflow needs an input node.")
82
+ if not any(node.type == "output" for node in definition.nodes):
83
+ raise ValueError("Workflow needs an output node.")
84
+
85
+ edge_ids = [edge.id for edge in definition.edges]
86
+ if any(not edge_id.strip() for edge_id in edge_ids):
87
+ raise ValueError("Workflow edge ids must not be empty.")
88
+ if len(set(edge_ids)) != len(edge_ids):
89
+ raise ValueError("Workflow edge ids must be unique.")
90
+
91
+ node_id_set = set(node_ids)
92
+ for edge in definition.edges:
93
+ if edge.source not in node_id_set or edge.target not in node_id_set:
94
+ raise ValueError("Workflow edges must connect existing nodes.")
95
+
96
+ return topological_node_ids(definition)
97
+
98
+
99
+ def workflow_requires_connection(definition: StoredWorkflowDefinition) -> bool:
100
+ return any(node.type == "agent" for node in definition.nodes)
101
+
102
+
103
+ def topological_node_ids(definition: StoredWorkflowDefinition) -> list[str]:
104
+ node_ids = [node.id for node in definition.nodes]
105
+ outgoing: dict[str, list[str]] = defaultdict(list)
106
+ indegree = {node_id: 0 for node_id in node_ids}
107
+ for edge in definition.edges:
108
+ outgoing[edge.source].append(edge.target)
109
+ indegree[edge.target] += 1
110
+
111
+ node_order = {node_id: index for index, node_id in enumerate(node_ids)}
112
+ ready = deque(
113
+ sorted(
114
+ [node_id for node_id, degree in indegree.items() if degree == 0],
115
+ key=lambda node_id: node_order[node_id],
116
+ )
117
+ )
118
+ ordered: list[str] = []
119
+ while ready:
120
+ node_id = ready.popleft()
121
+ ordered.append(node_id)
122
+ for target in sorted(
123
+ outgoing[node_id], key=lambda node_id: node_order[node_id]
124
+ ):
125
+ indegree[target] -= 1
126
+ if indegree[target] == 0:
127
+ ready.append(target)
128
+
129
+ if len(ordered) != len(node_ids):
130
+ raise ValueError("Workflow cannot contain cycles.")
131
+ return ordered
132
+
133
+
134
+ async def run_workflow_definition(
135
+ *,
136
+ completion: CompletionCallable | None,
137
+ connection: ProviderConnection | None,
138
+ definition: StoredWorkflowDefinition,
139
+ workflow_id: str,
140
+ ) -> WorkflowRunResponse:
141
+ ordered_ids = validate_workflow_definition(definition)
142
+ if workflow_requires_connection(definition) and connection is None:
143
+ raise ValueError("Choose a provider and model before running.")
144
+
145
+ nodes = {node.id: node for node in definition.nodes}
146
+ incoming_edges = edges_by_target(definition.edges)
147
+ results: dict[str, WorkflowNodeRunResult] = {
148
+ node.id: WorkflowNodeRunResult(id=node.id, status="pending")
149
+ for node in definition.nodes
150
+ }
151
+ outputs: dict[str, str] = {}
152
+ named_outputs: dict[str, str] = {}
153
+
154
+ for node_id in ordered_ids:
155
+ node = nodes[node_id]
156
+ results[node.id] = WorkflowNodeRunResult(id=node.id, status="running")
157
+ try:
158
+ output = await run_node(
159
+ completion=completion,
160
+ connection=connection,
161
+ incoming_edges=incoming_edges[node.id],
162
+ node=node,
163
+ outputs=outputs,
164
+ )
165
+ except Exception as error:
166
+ results[node.id] = WorkflowNodeRunResult(
167
+ error=str(error) or "Node could not be completed.",
168
+ id=node.id,
169
+ status="failed",
170
+ )
171
+ return WorkflowRunResponse(
172
+ node_results=list(results.values()),
173
+ outputs=named_outputs,
174
+ status="failed",
175
+ workflow_id=workflow_id,
176
+ )
177
+ outputs[node.id] = output
178
+ if node.type == "output":
179
+ named_outputs[node_output_key(node)] = output
180
+ results[node.id] = WorkflowNodeRunResult(
181
+ id=node.id,
182
+ output=output,
183
+ status="success",
184
+ )
185
+
186
+ return WorkflowRunResponse(
187
+ node_results=list(results.values()),
188
+ outputs=named_outputs,
189
+ status="success",
190
+ workflow_id=workflow_id,
191
+ )
192
+
193
+
194
+ async def run_node(
195
+ *,
196
+ completion: CompletionCallable | None,
197
+ connection: ProviderConnection | None,
198
+ incoming_edges: list[StoredWorkflowEdge],
199
+ node: StoredWorkflowNode,
200
+ outputs: Mapping[str, str],
201
+ ) -> str:
202
+ if node.type == "input":
203
+ return node_data_text(node, "default_value")
204
+ if node.type == "agent":
205
+ if connection is None:
206
+ raise ValueError("Choose a provider and model before running.")
207
+ prompt = render_template(
208
+ node_data_text(node, "prompt")
209
+ or joined_upstream_outputs(incoming_edges, outputs),
210
+ outputs,
211
+ )
212
+ response = await complete_chat(
213
+ connection,
214
+ [ChatMessage(role="user", content=prompt)],
215
+ completion=completion,
216
+ )
217
+ return response.content
218
+ if node.type == "merge":
219
+ upstream = upstream_outputs(incoming_edges, outputs)
220
+ if node_data_text(node, "merge_strategy") == "json":
221
+ return merge_json_outputs(upstream)
222
+ return "\n".join(output for output in upstream if output)
223
+ if node.type == "output":
224
+ return joined_upstream_outputs(incoming_edges, outputs)
225
+ raise ValueError("Node type is not supported.")
226
+
227
+
228
+ def edges_by_target(
229
+ edges: list[StoredWorkflowEdge],
230
+ ) -> dict[str, list[StoredWorkflowEdge]]:
231
+ grouped: dict[str, list[StoredWorkflowEdge]] = defaultdict(list)
232
+ for edge in edges:
233
+ grouped[edge.target].append(edge)
234
+ return grouped
235
+
236
+
237
+ def node_data_text(node: StoredWorkflowNode, key: str) -> str:
238
+ value = node.data.get(key, "")
239
+ if value is None:
240
+ return ""
241
+ if isinstance(value, str):
242
+ return value
243
+ return str(value)
244
+
245
+
246
+ def node_output_key(node: StoredWorkflowNode) -> str:
247
+ return node_data_text(node, "output_key") or node.id
248
+
249
+
250
+ def upstream_outputs(
251
+ incoming_edges: list[StoredWorkflowEdge],
252
+ outputs: Mapping[str, str],
253
+ ) -> list[str]:
254
+ return [outputs[edge.source] for edge in incoming_edges if edge.source in outputs]
255
+
256
+
257
+ def joined_upstream_outputs(
258
+ incoming_edges: list[StoredWorkflowEdge],
259
+ outputs: Mapping[str, str],
260
+ ) -> str:
261
+ return "\n".join(
262
+ output for output in upstream_outputs(incoming_edges, outputs) if output
263
+ )
264
+
265
+
266
+ def render_template(template: str, outputs: Mapping[str, str]) -> str:
267
+ return PLACEHOLDER_PATTERN.sub(
268
+ lambda match: outputs.get(match.group(1), ""),
269
+ template,
270
+ )
271
+
272
+
273
+ def merge_json_outputs(upstream: list[str]) -> str:
274
+ merged: dict[str, object] = {}
275
+ for output in upstream:
276
+ try:
277
+ parsed = json.loads(output)
278
+ except json.JSONDecodeError:
279
+ continue
280
+ if isinstance(parsed, dict):
281
+ merged.update(parsed)
282
+ return json.dumps(merged, ensure_ascii=False)
File without changes
@@ -0,0 +1,249 @@
1
+ import json
2
+ import os
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Literal
5
+
6
+ from fastapi import HTTPException
7
+
8
+ from flowent.compact import transcript_messages_after
9
+ from flowent.llm import ChatMessage
10
+ from flowent.storage import (
11
+ StoredCompactionCheckpoint,
12
+ StoredMessage,
13
+ StoredSettings,
14
+ StoredState,
15
+ )
16
+ from flowent.usage import (
17
+ TokenUsageInfo,
18
+ current_model_context_window,
19
+ estimated_token_usage_for_messages,
20
+ recompute_context_usage,
21
+ )
22
+ from flowent.workspace.output import error_context_summary, message_error_items
23
+
24
+ COMPACTED_CONTEXT_MARKER = "Context compacted"
25
+ OPTIMIZED_CONTEXT_MARKER = "Context optimized"
26
+ DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO = 0.95
27
+
28
+
29
+ def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
30
+ for index in range(len(messages) - 1, -1, -1):
31
+ message = messages[index]
32
+ if message.author == "system" and is_context_marker(message):
33
+ return index
34
+ return None
35
+
36
+
37
+ def is_context_marker(message: StoredMessage) -> bool:
38
+ return message.content in {COMPACTED_CONTEXT_MARKER, OPTIMIZED_CONTEXT_MARKER}
39
+
40
+
41
+ def auto_compact_token_limit(context_window: int) -> int:
42
+ raw_limit = os.environ.get("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "")
43
+ if not raw_limit:
44
+ return max(0, int(context_window * DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO))
45
+ try:
46
+ return max(0, int(raw_limit))
47
+ except ValueError:
48
+ return max(0, int(context_window * DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO))
49
+
50
+
51
+ def should_auto_compact(
52
+ messages: list[ChatMessage],
53
+ *,
54
+ context_window: int,
55
+ ) -> bool:
56
+ token_limit = auto_compact_token_limit(context_window)
57
+ if token_limit <= 0:
58
+ return False
59
+ return (
60
+ estimated_token_usage_for_messages(
61
+ [message.model_dump() for message in messages]
62
+ ).total_tokens
63
+ >= token_limit
64
+ )
65
+
66
+
67
+ def model_visible_messages_for_usage(
68
+ messages: Sequence[Mapping[str, object]],
69
+ ) -> list[dict[str, object]]:
70
+ return [
71
+ dict(message)
72
+ for message in messages
73
+ if message.get("role") in {"system", "user", "assistant", "tool"}
74
+ ]
75
+
76
+
77
+ def usage_event_data(usage_info: TokenUsageInfo) -> dict[str, object]:
78
+ return {"usage_info": usage_info.model_dump()}
79
+
80
+
81
+ def update_context_usage_for_response(
82
+ usage_info: TokenUsageInfo | None,
83
+ *,
84
+ messages: Sequence[Mapping[str, object]],
85
+ output_content: str,
86
+ output_tools: Sequence[Mapping[str, object]] = (),
87
+ model_context_window: int,
88
+ ) -> TokenUsageInfo:
89
+ return recompute_context_usage(
90
+ usage_info,
91
+ estimated_token_usage_for_messages(
92
+ [
93
+ *model_visible_messages_for_usage(messages),
94
+ *model_visible_response_messages_for_usage(
95
+ output_content, output_tools
96
+ ),
97
+ ],
98
+ ).total_tokens,
99
+ model_context_window=model_context_window,
100
+ )
101
+
102
+
103
+ def model_visible_response_messages_for_usage(
104
+ output_content: str,
105
+ output_tools: Sequence[Mapping[str, object]],
106
+ ) -> list[dict[str, object]]:
107
+ visible_messages: list[dict[str, object]] = []
108
+ for index, tool in enumerate(output_tools):
109
+ tool_id = str(tool.get("id") or f"call_{index}")
110
+ arguments = tool.get("arguments")
111
+ visible_messages.append(
112
+ {
113
+ "role": "assistant",
114
+ "content": "",
115
+ "tool_calls": [
116
+ {
117
+ "id": tool_id,
118
+ "type": "function",
119
+ "function": {
120
+ "name": str(tool.get("name") or ""),
121
+ "arguments": json.dumps(
122
+ arguments if arguments is not None else {},
123
+ ensure_ascii=False,
124
+ ),
125
+ },
126
+ }
127
+ ],
128
+ }
129
+ )
130
+ visible_messages.append(
131
+ {
132
+ "role": "tool",
133
+ "tool_call_id": tool_id,
134
+ "content": str(tool.get("content") or ""),
135
+ }
136
+ )
137
+ if output_content:
138
+ visible_messages.append({"role": "assistant", "content": output_content})
139
+ return visible_messages
140
+
141
+
142
+ def usage_info_for_model(
143
+ usage_info: TokenUsageInfo | None,
144
+ model_context_window: int,
145
+ ) -> TokenUsageInfo | None:
146
+ if usage_info is None:
147
+ return None
148
+ return usage_info.model_copy(update={"model_context_window": model_context_window})
149
+
150
+
151
+ def context_window_for_settings(settings: StoredSettings) -> int:
152
+ if settings.context_window_limit is not None:
153
+ return settings.context_window_limit
154
+ return current_model_context_window(settings.selected_model)
155
+
156
+
157
+ def state_with_current_model_context_window(state: StoredState) -> StoredState:
158
+ model_context_window = context_window_for_settings(state.settings)
159
+ return state.model_copy(
160
+ update={
161
+ "messages": [
162
+ message.model_copy(
163
+ update={
164
+ "usage_info": usage_info_for_model(
165
+ message.usage_info,
166
+ model_context_window,
167
+ )
168
+ }
169
+ )
170
+ if message.usage_info is not None
171
+ else message
172
+ for message in state.messages
173
+ ],
174
+ "usage_info": usage_info_for_model(
175
+ state.usage_info,
176
+ model_context_window,
177
+ ),
178
+ }
179
+ )
180
+
181
+
182
+ def workspace_chat_messages(
183
+ messages: list[StoredMessage],
184
+ compacted_context: str = "",
185
+ checkpoint: StoredCompactionCheckpoint | None = None,
186
+ ) -> list[ChatMessage]:
187
+ chat_messages: list[ChatMessage] = []
188
+
189
+ if checkpoint is not None:
190
+ chat_messages.extend(checkpoint.replacement_history)
191
+ visible_messages = transcript_messages_after(
192
+ messages,
193
+ checkpoint.source_message_id,
194
+ )
195
+ for message in visible_messages:
196
+ if message.author == "system" and is_context_marker(message):
197
+ continue
198
+ if message.author not in ("user", "assistant"):
199
+ raise HTTPException(
200
+ status_code=400, detail="Message history is invalid."
201
+ )
202
+ if message.author == "assistant":
203
+ errors = message_error_items(message)
204
+ if errors:
205
+ chat_messages.extend(
206
+ ChatMessage(
207
+ role="assistant", content=error_context_summary(error)
208
+ )
209
+ for error in errors
210
+ )
211
+ continue
212
+ checkpoint_role: Literal["user", "assistant"] = (
213
+ "user" if message.author == "user" else "assistant"
214
+ )
215
+ chat_messages.append(
216
+ ChatMessage(role=checkpoint_role, content=message.content)
217
+ )
218
+ return chat_messages
219
+
220
+ marker_index = latest_compacted_context_index(messages)
221
+ visible_messages = messages
222
+
223
+ if compacted_context and marker_index is not None:
224
+ chat_messages.extend(
225
+ [
226
+ ChatMessage(role="user", content=COMPACTED_CONTEXT_MARKER),
227
+ ChatMessage(role="assistant", content=compacted_context),
228
+ ]
229
+ )
230
+ visible_messages = messages[marker_index + 1 :]
231
+
232
+ for message in visible_messages:
233
+ if message.author == "system" and is_context_marker(message):
234
+ continue
235
+ if message.author not in ("user", "assistant"):
236
+ raise HTTPException(status_code=400, detail="Message history is invalid.")
237
+ if message.author == "assistant":
238
+ errors = message_error_items(message)
239
+ if errors:
240
+ chat_messages.extend(
241
+ ChatMessage(role="assistant", content=error_context_summary(error))
242
+ for error in errors
243
+ )
244
+ continue
245
+ role: Literal["user", "assistant"] = (
246
+ "user" if message.author == "user" else "assistant"
247
+ )
248
+ chat_messages.append(ChatMessage(role=role, content=message.content))
249
+ return chat_messages