sim-atlas 0.0.1__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.
Files changed (38) hide show
  1. sim_atlas/__init__.py +0 -0
  2. sim_atlas/agent/__init__.py +3 -0
  3. sim_atlas/agent/_prompt.py +66 -0
  4. sim_atlas/agent/_runner.py +213 -0
  5. sim_atlas/agent/_sse.py +71 -0
  6. sim_atlas/agent/tools/__init__.py +229 -0
  7. sim_atlas/agent/tools/_errors.py +2 -0
  8. sim_atlas/agent/tools/_graph.py +328 -0
  9. sim_atlas/agent/tools/_search.py +255 -0
  10. sim_atlas/ai.py +180 -0
  11. sim_atlas/cli/__init__.py +4 -0
  12. sim_atlas/cli/server.py +37 -0
  13. sim_atlas/cli/token.py +38 -0
  14. sim_atlas/exceptions.py +6 -0
  15. sim_atlas/file_system_storage.py +480 -0
  16. sim_atlas/main.py +329 -0
  17. sim_atlas/models.py +348 -0
  18. sim_atlas/security.py +39 -0
  19. sim_atlas/settings.py +228 -0
  20. sim_atlas/static/frontend/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  21. sim_atlas/static/frontend/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  22. sim_atlas/static/frontend/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  23. sim_atlas/static/frontend/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  24. sim_atlas/static/frontend/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  25. sim_atlas/static/frontend/assets/index-Bj1UJeA8.css +1 -0
  26. sim_atlas/static/frontend/assets/index-vns5LrWm.js +82 -0
  27. sim_atlas/static/frontend/index.html +14 -0
  28. sim_atlas/static/ide/assets/index-D7pRfJJj.js +116 -0
  29. sim_atlas/static/ide/assets/index-DeIeWIGr.css +1 -0
  30. sim_atlas/static/ide/index.html +16 -0
  31. sim_atlas/storage_interface.py +96 -0
  32. sim_atlas/type_utils.py +185 -0
  33. sim_atlas/voyage_ai.py +25 -0
  34. sim_atlas-0.0.1.dist-info/METADATA +32 -0
  35. sim_atlas-0.0.1.dist-info/RECORD +38 -0
  36. sim_atlas-0.0.1.dist-info/WHEEL +4 -0
  37. sim_atlas-0.0.1.dist-info/entry_points.txt +3 -0
  38. sim_atlas-0.0.1.dist-info/licenses/LICENSE +28 -0
sim_atlas/__init__.py ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from ._runner import run_agent_stream
2
+
3
+ __all__ = ["run_agent_stream"]
@@ -0,0 +1,66 @@
1
+ from ..models import AgentRequest, Annotation, GraphNodeContext
2
+ from ..storage_interface import StorageInterface
3
+ from .tools import get_tool_prompt_guidance_lines
4
+
5
+
6
+ def _port_str(a: Annotation) -> str:
7
+ s = f"{a.label}:{a.datatype or '?'}"
8
+ if a.unit:
9
+ s += f"[{a.unit}]"
10
+ if a.quantity:
11
+ s += f"({a.quantity})"
12
+ if a.description:
13
+ s += f" — {a.description}"
14
+ return s
15
+
16
+
17
+ def _node_to_context(node: GraphNodeContext) -> str:
18
+ inputs = ", ".join(_port_str(a) for a in node.inputs)
19
+ outputs = ", ".join(_port_str(a) for a in node.outputs)
20
+ desc = f" - {node.short_description}" if node.short_description else ""
21
+ atlas = f" (atlas_id={node.atlas_node_id})" if node.node_kind == "function" else ""
22
+ return f" [{node.node_kind}:{node.graph_id}] {node.name}{atlas}{desc}\n in: {inputs}\n out: {outputs}"
23
+
24
+
25
+ def build_system_prompt(request: AgentRequest, storage: StorageInterface) -> str:
26
+ node_lines = "\n".join(_node_to_context(n) for n in request.nodes)
27
+ edge_lines = "\n".join(
28
+ f" {e.source_graph_id}/{e.source_handle} -> {e.target_graph_id}/{e.target_handle}"
29
+ for e in request.edges
30
+ )
31
+ opts = storage.get_filter_options()
32
+ filter_section = (
33
+ "## Available filter values\n"
34
+ "Use only values from these lists when supplying filter arguments to "
35
+ "find_compatible_nodes.\n"
36
+ f"datatypes: {', '.join(sorted(opts.datatypes)) or '(none)'}\n"
37
+ f"units: {', '.join(sorted(opts.units)) or '(none)'}\n"
38
+ f"quantities: {', '.join(sorted(opts.quantities)) or '(none)'}"
39
+ )
40
+ tool_guidance = "\n".join(f"- {line}" for line in get_tool_prompt_guidance_lines())
41
+
42
+ return f"""You are an expert workflow builder for scientific simulation pipelines.
43
+ You have access to a catalog of simulation nodes (functions) that can be searched and connected.
44
+
45
+ ## Current graph
46
+
47
+ ### Nodes
48
+ {node_lines or " (empty)"}
49
+
50
+ ### Edges
51
+ {edge_lines or " (none)"}
52
+
53
+ ## Your role
54
+ You are an editor of an existing workflow graph.
55
+ Treat the current graph as intentional work that should be preserved unless change is necessary.
56
+ Make the minimum changes needed to satisfy the user's request.
57
+ Prefer adding new nodes and edges over modifying or removing existing ones.
58
+ Only remove or rewire existing nodes when the user explicitly asks, or when it is genuinely necessary to produce a correct result.
59
+
60
+ ## Tools
61
+ - If you face doubts, hard to solve problems, inconsistencies, or multiple options, ask the user a clarification question in plain text instead of guessing.
62
+ {tool_guidance}
63
+ - When you are finished, respond with a concise summary of only the changes you made.
64
+
65
+ {filter_section}
66
+ """
@@ -0,0 +1,213 @@
1
+ import json
2
+ import logging
3
+ import uuid
4
+ from collections.abc import AsyncGenerator
5
+ from typing import Any, cast
6
+
7
+ from langfuse import Langfuse, propagate_attributes
8
+ from langfuse.openai import AsyncOpenAI # pyright: ignore[reportPrivateImportUsage]
9
+ from openai.types.chat import (
10
+ ChatCompletionMessageParam,
11
+ ChatCompletionMessageToolCall,
12
+ )
13
+ from pydantic import ValidationError
14
+
15
+ from ..models import AgentRequest
16
+ from ..settings import load_settings
17
+ from ..storage_interface import StorageInterface
18
+ from ._prompt import build_system_prompt
19
+ from ._sse import (
20
+ ErrorEvent,
21
+ GraphUpdateEvent,
22
+ MessageEvent,
23
+ ReasoningEvent,
24
+ ToolCallEvent,
25
+ ToolResultEvent,
26
+ TruncatedEvent,
27
+ ValidationEvent,
28
+ to_sse,
29
+ )
30
+ from .tools import TOOLS, ScratchGraph, ToolError, execute_tool, validate_graph
31
+
32
+ logger = logging.getLogger(__name__)
33
+ logger.setLevel(logging.DEBUG)
34
+
35
+
36
+ def _graph_update_event(scratch: ScratchGraph) -> GraphUpdateEvent:
37
+ return GraphUpdateEvent(
38
+ nodes=[n.model_dump(exclude_none=True) for n in scratch.nodes.values()],
39
+ edges=[e.model_dump() for e in scratch.edges],
40
+ )
41
+
42
+
43
+ async def run_agent_stream(
44
+ request: AgentRequest,
45
+ storage: StorageInterface,
46
+ ) -> AsyncGenerator[str, None]:
47
+ """Async generator that streams SSE events while running the agent loop."""
48
+ settings = load_settings()
49
+ assert settings.llm_api_key and settings.llm_base_url and settings.llm_chat_model
50
+ lf = Langfuse(
51
+ public_key=settings.langfuse_public_key,
52
+ secret_key=settings.langfuse_secret_key,
53
+ host=settings.langfuse_host,
54
+ environment=settings.langfuse_environment,
55
+ )
56
+ client = AsyncOpenAI(api_key=settings.llm_api_key, base_url=settings.llm_base_url)
57
+ scratch = ScratchGraph(request.nodes, request.edges)
58
+
59
+ history_messages: list[ChatCompletionMessageParam] = [
60
+ cast(ChatCompletionMessageParam, {"role": m.role, "content": m.content})
61
+ for m in request.history
62
+ ]
63
+ messages: list[ChatCompletionMessageParam] = [
64
+ {"role": "system", "content": build_system_prompt(request, storage)},
65
+ *history_messages,
66
+ {"role": "user", "content": request.query},
67
+ ]
68
+
69
+ session_id = request.session_id or str(uuid.uuid4())
70
+ final_message: str | None = None
71
+ correction_rounds = 0
72
+ total_input_tokens = 0
73
+ total_output_tokens = 0
74
+ max_turns = settings.agent_max_iterations
75
+ with (
76
+ lf.start_as_current_observation(
77
+ as_type="agent",
78
+ name="workflow-builder",
79
+ input=request.query,
80
+ ) as root_span,
81
+ propagate_attributes(user_id=request.user_id, session_id=session_id),
82
+ ):
83
+ try:
84
+ # Runaway-check loop: exits naturally (break) when the agent finishes,
85
+ # or falls through (no break) when the turn limit is reached.
86
+ for _ in range(max_turns):
87
+ response = await client.chat.completions.create(
88
+ model=settings.llm_chat_model,
89
+ messages=messages,
90
+ tools=TOOLS,
91
+ tool_choice="auto",
92
+ )
93
+ choice = response.choices[0]
94
+ logger.debug(
95
+ "LLM response message: %s",
96
+ json.dumps(choice.message.model_dump(exclude_unset=True), indent=2),
97
+ )
98
+ if response.usage:
99
+ total_input_tokens += response.usage.prompt_tokens
100
+ total_output_tokens += response.usage.completion_tokens
101
+ messages.append(
102
+ cast(
103
+ ChatCompletionMessageParam,
104
+ choice.message.model_dump(exclude_unset=True),
105
+ )
106
+ )
107
+
108
+ if not choice.message.tool_calls:
109
+ final_message = choice.message.content or "(no response)"
110
+ with lf.start_as_current_observation(
111
+ name="graph_validation"
112
+ ) as span:
113
+ validation_errors = validate_graph(scratch, storage)
114
+ span.update(
115
+ output={"errors": validation_errors},
116
+ )
117
+ if not validation_errors:
118
+ break
119
+ # Emit a validation event so the UI can show a correction round.
120
+ yield to_sse(ValidationEvent(errors=validation_errors))
121
+ correction_rounds += 1
122
+ error_text = "\n".join(f"- {e}" for e in validation_errors)
123
+ logger.debug(
124
+ "Graph validation errors (stream); asking agent to correct:\n%s",
125
+ error_text,
126
+ )
127
+ correction_message = (
128
+ "The current graph has validation errors. "
129
+ "Please fix them using the available tools:\n" + error_text
130
+ )
131
+ messages.append({"role": "user", "content": correction_message})
132
+ continue
133
+
134
+ reasoning = (
135
+ getattr(choice.message, "reasoning", None)
136
+ or choice.message.content
137
+ or None
138
+ )
139
+ if reasoning:
140
+ yield to_sse(ReasoningEvent(content=reasoning))
141
+ for tc in choice.message.tool_calls:
142
+ if not isinstance(tc, ChatCompletionMessageToolCall):
143
+ continue
144
+ args: dict[str, Any] = json.loads(tc.function.arguments)
145
+ yield to_sse(ToolCallEvent(name=tc.function.name, args=args))
146
+
147
+ try:
148
+ result = await execute_tool(
149
+ tc.function.name, args, storage, scratch
150
+ )
151
+ content = result
152
+ except (ValidationError, ToolError) as exc:
153
+ msg = (
154
+ f"Invalid arguments for '{tc.function.name}': {exc}"
155
+ if isinstance(exc, ValidationError)
156
+ else str(exc)
157
+ )
158
+ content = json.dumps({"error": msg})
159
+
160
+ yield to_sse(
161
+ ToolResultEvent(name=tc.function.name, content=content)
162
+ )
163
+ messages.append(
164
+ {
165
+ "role": "tool",
166
+ "tool_call_id": tc.id,
167
+ "content": content,
168
+ }
169
+ )
170
+
171
+ yield to_sse(_graph_update_event(scratch))
172
+
173
+ truncated = final_message is None
174
+ if truncated:
175
+ # Turn limit reached without natural completion.
176
+ # Make one tool-free summary call so the history entry is honest.
177
+ messages.append(
178
+ {
179
+ "role": "user",
180
+ "content": (
181
+ "You have reached the maximum number of turns. "
182
+ "Summarise in 2-3 sentences: what you have built so far "
183
+ "and what still needs to be done to fulfil the original request. "
184
+ "End with a clear statement of the next step the user should ask you to take."
185
+ ),
186
+ }
187
+ )
188
+
189
+ summary_response = await client.chat.completions.create(
190
+ model=settings.llm_chat_model,
191
+ messages=messages,
192
+ tool_choice="none",
193
+ )
194
+ summary_choice = summary_response.choices[0]
195
+ final_message = summary_choice.message.content or "(turn limit reached)"
196
+ if summary_response.usage:
197
+ total_input_tokens += summary_response.usage.prompt_tokens
198
+ total_output_tokens += summary_response.usage.completion_tokens
199
+ logger.debug("Turn limit reached; summary: %s", final_message)
200
+
201
+ root_span.update(output=final_message)
202
+ yield to_sse(_graph_update_event(scratch))
203
+ yield to_sse(MessageEvent(content=final_message))
204
+ if truncated:
205
+ yield to_sse(TruncatedEvent())
206
+
207
+ lf.flush()
208
+ except Exception as exc: # noqa: BLE001
209
+ logger.exception("Agent stream error")
210
+ root_span.update(level="ERROR", status_message=str(exc))
211
+ yield to_sse(ErrorEvent(message=str(exc)))
212
+ finally:
213
+ lf.flush()
@@ -0,0 +1,71 @@
1
+ from typing import Any, Literal
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ReasoningEvent(BaseModel):
7
+ type: Literal["reasoning"] = "reasoning"
8
+ content: str
9
+
10
+
11
+ class ToolCallEvent(BaseModel):
12
+ type: Literal["tool_call"] = "tool_call"
13
+ name: str
14
+ args: dict[str, Any]
15
+
16
+
17
+ class ToolResultEvent(BaseModel):
18
+ type: Literal["tool_result"] = "tool_result"
19
+ name: str
20
+ content: str
21
+
22
+
23
+ class MessageEvent(BaseModel):
24
+ type: Literal["message"] = "message"
25
+ content: str
26
+
27
+
28
+ class ClarificationEvent(BaseModel):
29
+ type: Literal["clarification"] = "clarification"
30
+ question: str
31
+ options: list[str]
32
+
33
+
34
+ class ValidationEvent(BaseModel):
35
+ type: Literal["validation"] = "validation"
36
+ errors: list[str]
37
+
38
+
39
+ class ErrorEvent(BaseModel):
40
+ type: Literal["error"] = "error"
41
+ message: str
42
+
43
+
44
+ class GraphUpdateEvent(BaseModel):
45
+ type: Literal["graph_update"] = "graph_update"
46
+ nodes: list[dict[str, Any]]
47
+ edges: list[dict[str, Any]]
48
+
49
+
50
+ class TruncatedEvent(BaseModel):
51
+ """Emitted when the runaway-check loop exhausts all turns without natural completion."""
52
+
53
+ type: Literal["truncated"] = "truncated"
54
+
55
+
56
+ Event = (
57
+ ReasoningEvent
58
+ | ToolCallEvent
59
+ | ToolResultEvent
60
+ | MessageEvent
61
+ | ClarificationEvent
62
+ | ValidationEvent
63
+ | ErrorEvent
64
+ | GraphUpdateEvent
65
+ | TruncatedEvent
66
+ )
67
+
68
+
69
+ def to_sse(event: Event) -> str:
70
+ """Format an event payload as an SSE data frame."""
71
+ return f"data: {event.model_dump_json()}\n\n"
@@ -0,0 +1,229 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from langfuse import get_client
5
+ from openai.types.chat import ChatCompletionToolParam
6
+ from pydantic import BaseModel
7
+
8
+ from ...storage_interface import StorageInterface
9
+ from ._errors import ToolError
10
+ from ._graph import (
11
+ ADD_EDGE_DESCRIPTION,
12
+ ADD_FUNCTION_NODE_DESCRIPTION,
13
+ ADD_INPUT_NODE_DESCRIPTION,
14
+ ADD_OUTPUT_NODE_DESCRIPTION,
15
+ REMOVE_EDGE_DESCRIPTION,
16
+ REMOVE_NODE_DESCRIPTION,
17
+ AddEdgeInput,
18
+ AddEdgeResult,
19
+ AddFunctionNodeInput,
20
+ AddFunctionNodeResult,
21
+ AddInputNodeInput,
22
+ AddInputNodeResult,
23
+ AddOutputNodeInput,
24
+ AddOutputNodeResult,
25
+ RemoveEdgeInput,
26
+ RemoveEdgeResult,
27
+ RemoveNodeInput,
28
+ RemoveNodeResult,
29
+ ScratchGraph,
30
+ execute_add_edge,
31
+ execute_add_function_node,
32
+ execute_add_input_node,
33
+ execute_add_output_node,
34
+ execute_remove_edge,
35
+ execute_remove_node,
36
+ validate_graph,
37
+ )
38
+ from ._search import (
39
+ FIND_COMPATIBLE_DESCRIPTION,
40
+ GET_NODE_DETAILS_DESCRIPTION,
41
+ SEARCH_NODES_DESCRIPTION,
42
+ FindCompatibleNodesInput,
43
+ FindCompatibleNodesResult,
44
+ GetNodeDetailsInput,
45
+ GetNodeDetailsResult,
46
+ SearchNodesInput,
47
+ SearchNodesResult,
48
+ execute_find_compatible_nodes,
49
+ execute_get_node_details,
50
+ execute_search_nodes,
51
+ )
52
+
53
+ ExecutorFn = Any
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ToolDef:
58
+ name: str
59
+ description: str
60
+ prompt_guidance: str
61
+ input_model: type[BaseModel]
62
+ output_model: type[BaseModel]
63
+ executor: ExecutorFn
64
+
65
+
66
+ TOOL_REGISTRY: list[ToolDef] = [
67
+ ToolDef(
68
+ name="search_nodes",
69
+ description=SEARCH_NODES_DESCRIPTION,
70
+ prompt_guidance="Use search_nodes for intent-based discovery.",
71
+ input_model=SearchNodesInput,
72
+ output_model=SearchNodesResult,
73
+ executor=execute_search_nodes,
74
+ ),
75
+ ToolDef(
76
+ name="find_compatible_nodes",
77
+ description=FIND_COMPATIBLE_DESCRIPTION,
78
+ prompt_guidance=(
79
+ "Use find_compatible_nodes when you know a specific port signature "
80
+ "and want to find what connects to it."
81
+ ),
82
+ input_model=FindCompatibleNodesInput,
83
+ output_model=FindCompatibleNodesResult,
84
+ executor=execute_find_compatible_nodes,
85
+ ),
86
+ ToolDef(
87
+ name="get_node_details",
88
+ description=GET_NODE_DETAILS_DESCRIPTION,
89
+ prompt_guidance=(
90
+ "Use get_node_details when you need more information about a specific "
91
+ "node before deciding."
92
+ ),
93
+ input_model=GetNodeDetailsInput,
94
+ output_model=GetNodeDetailsResult,
95
+ executor=execute_get_node_details,
96
+ ),
97
+ ToolDef(
98
+ name="add_function_node",
99
+ description=ADD_FUNCTION_NODE_DESCRIPTION,
100
+ prompt_guidance=(
101
+ "Use add_function_node to add a node from the catalog; it returns "
102
+ "the assigned graph_id and port metadata — use that graph_id in "
103
+ "subsequent add_edge calls."
104
+ ),
105
+ input_model=AddFunctionNodeInput,
106
+ output_model=AddFunctionNodeResult,
107
+ executor=execute_add_function_node,
108
+ ),
109
+ ToolDef(
110
+ name="add_input_node",
111
+ description=ADD_INPUT_NODE_DESCRIPTION,
112
+ prompt_guidance=(
113
+ "Use add_input_node to expose a parameter or data source. "
114
+ "The single output port is always named 'output'."
115
+ ),
116
+ input_model=AddInputNodeInput,
117
+ output_model=AddInputNodeResult,
118
+ executor=execute_add_input_node,
119
+ ),
120
+ ToolDef(
121
+ name="add_output_node",
122
+ description=ADD_OUTPUT_NODE_DESCRIPTION,
123
+ prompt_guidance=(
124
+ "Use add_output_node to expose a result. "
125
+ "The single input port is always named 'input'."
126
+ ),
127
+ input_model=AddOutputNodeInput,
128
+ output_model=AddOutputNodeResult,
129
+ executor=execute_add_output_node,
130
+ ),
131
+ ToolDef(
132
+ name="add_edge",
133
+ description=ADD_EDGE_DESCRIPTION,
134
+ prompt_guidance=(
135
+ "Use add_edge to connect nodes; verify port names using the port "
136
+ "metadata returned by add_function_node or get_node_details."
137
+ ),
138
+ input_model=AddEdgeInput,
139
+ output_model=AddEdgeResult,
140
+ executor=execute_add_edge,
141
+ ),
142
+ ToolDef(
143
+ name="remove_edge",
144
+ description=REMOVE_EDGE_DESCRIPTION,
145
+ prompt_guidance=(
146
+ "Use remove_edge to disconnect two nodes by specifying all four "
147
+ "endpoint identifiers."
148
+ ),
149
+ input_model=RemoveEdgeInput,
150
+ output_model=RemoveEdgeResult,
151
+ executor=execute_remove_edge,
152
+ ),
153
+ ToolDef(
154
+ name="remove_node",
155
+ description=REMOVE_NODE_DESCRIPTION,
156
+ prompt_guidance=(
157
+ "Use remove_node to delete an existing node and all its connected edges."
158
+ ),
159
+ input_model=RemoveNodeInput,
160
+ output_model=RemoveNodeResult,
161
+ executor=execute_remove_node,
162
+ ),
163
+ ]
164
+
165
+ _TOOL_MAP: dict[str, ToolDef] = {tool.name: tool for tool in TOOL_REGISTRY}
166
+
167
+
168
+ def _schema_from_input_model(model: type[BaseModel]) -> dict[str, Any]:
169
+ schema = model.model_json_schema()
170
+ return {
171
+ "type": "object",
172
+ "properties": schema.get("properties", {}),
173
+ "required": schema.get("required", []),
174
+ }
175
+
176
+
177
+ def _build_tools_list() -> list[ChatCompletionToolParam]:
178
+ return [
179
+ {
180
+ "type": "function",
181
+ "function": {
182
+ "name": tool.name,
183
+ "description": tool.description,
184
+ "parameters": _schema_from_input_model(tool.input_model),
185
+ },
186
+ }
187
+ for tool in TOOL_REGISTRY
188
+ ]
189
+
190
+
191
+ TOOLS: list[ChatCompletionToolParam] = _build_tools_list()
192
+
193
+
194
+ def get_tool_prompt_guidance_lines() -> list[str]:
195
+ return [tool.prompt_guidance for tool in TOOL_REGISTRY]
196
+
197
+
198
+ async def execute_tool(
199
+ tool_name: str,
200
+ tool_args: dict[str, Any],
201
+ storage: StorageInterface,
202
+ scratch: ScratchGraph,
203
+ ) -> str:
204
+ lf = get_client()
205
+ with lf.start_as_current_observation(
206
+ as_type="tool",
207
+ name=tool_name,
208
+ input=tool_args,
209
+ ) as span:
210
+ tool = _TOOL_MAP.get(tool_name)
211
+ if tool is None:
212
+ raise ToolError(f"Unknown tool '{tool_name}'.")
213
+
214
+ validated_args = tool.input_model.model_validate(tool_args)
215
+ result = await tool.executor(validated_args, storage, scratch)
216
+ span.update(output=result)
217
+ return result
218
+
219
+
220
+ __all__ = [
221
+ "TOOL_REGISTRY",
222
+ "TOOLS",
223
+ "ScratchGraph",
224
+ "ToolDef",
225
+ "ToolError",
226
+ "execute_tool",
227
+ "get_tool_prompt_guidance_lines",
228
+ "validate_graph",
229
+ ]
@@ -0,0 +1,2 @@
1
+ class ToolError(Exception):
2
+ """Raised when a tool execution fails in a recoverable way."""