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.
- sim_atlas/__init__.py +0 -0
- sim_atlas/agent/__init__.py +3 -0
- sim_atlas/agent/_prompt.py +66 -0
- sim_atlas/agent/_runner.py +213 -0
- sim_atlas/agent/_sse.py +71 -0
- sim_atlas/agent/tools/__init__.py +229 -0
- sim_atlas/agent/tools/_errors.py +2 -0
- sim_atlas/agent/tools/_graph.py +328 -0
- sim_atlas/agent/tools/_search.py +255 -0
- sim_atlas/ai.py +180 -0
- sim_atlas/cli/__init__.py +4 -0
- sim_atlas/cli/server.py +37 -0
- sim_atlas/cli/token.py +38 -0
- sim_atlas/exceptions.py +6 -0
- sim_atlas/file_system_storage.py +480 -0
- sim_atlas/main.py +329 -0
- sim_atlas/models.py +348 -0
- sim_atlas/security.py +39 -0
- sim_atlas/settings.py +228 -0
- sim_atlas/static/frontend/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- sim_atlas/static/frontend/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- sim_atlas/static/frontend/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- sim_atlas/static/frontend/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- sim_atlas/static/frontend/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- sim_atlas/static/frontend/assets/index-Bj1UJeA8.css +1 -0
- sim_atlas/static/frontend/assets/index-vns5LrWm.js +82 -0
- sim_atlas/static/frontend/index.html +14 -0
- sim_atlas/static/ide/assets/index-D7pRfJJj.js +116 -0
- sim_atlas/static/ide/assets/index-DeIeWIGr.css +1 -0
- sim_atlas/static/ide/index.html +16 -0
- sim_atlas/storage_interface.py +96 -0
- sim_atlas/type_utils.py +185 -0
- sim_atlas/voyage_ai.py +25 -0
- sim_atlas-0.0.1.dist-info/METADATA +32 -0
- sim_atlas-0.0.1.dist-info/RECORD +38 -0
- sim_atlas-0.0.1.dist-info/WHEEL +4 -0
- sim_atlas-0.0.1.dist-info/entry_points.txt +3 -0
- sim_atlas-0.0.1.dist-info/licenses/LICENSE +28 -0
sim_atlas/__init__.py
ADDED
|
File without changes
|
|
@@ -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()
|
sim_atlas/agent/_sse.py
ADDED
|
@@ -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
|
+
]
|