cg-code-graph 0.10.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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""LLM tools, MCP servers / clients and agents (#66) -> the #31 endpoint model (codegraph/protocols).
|
|
2
|
+
|
|
3
|
+
Only modules importing an AI SDK take part (mcp, fastmcp, openai, agents, anthropic, langchain*, langgraph,
|
|
4
|
+
llama_index); other code is untouched.
|
|
5
|
+
|
|
6
|
+
endpoint:mcp_tool:<server>/<name>, mcp_resource:<server>/<uri>, mcp_prompt:<server>/<name>
|
|
7
|
+
receive FastMCP / MCPServer `@mcp.tool()` / `@mcp.resource("notes://{id}")` / `@mcp.prompt()`, `mcp.tool()(fn)`,
|
|
8
|
+
`add_tool(fn)` and local decorators that register their argument (the registrations refs.py records);
|
|
9
|
+
low-level `@server.call_tool()` handlers branching on `if name == "x"` (resolved)
|
|
10
|
+
send clients: `session.call_tool("x")`, `read_resource("uri")`, `get_prompt("x")` -> `*/<name>` (any server;
|
|
11
|
+
MATCHES_ENDPOINT pairs it with every server tool of that name)
|
|
12
|
+
endpoint:llm_tool:<name>
|
|
13
|
+
receive LangChain `@tool` / `@tool("name")`, `StructuredTool.from_function` / `Tool(name=, func=)`, BaseTool
|
|
14
|
+
subclasses (`name` class attribute -> `_run` / `_arun`), Agents SDK `@function_tool` (name_override),
|
|
15
|
+
Anthropic `@beta_tool`, LlamaIndex `FunctionTool.from_defaults(fn=, name=)` (exact); module dict
|
|
16
|
+
registries `TOOLS = {"x": fn}` / `{f.__name__: f for f in [a, b]}` and `if name == "x":` / `match name:`
|
|
17
|
+
branches in an agent loop (resolved)
|
|
18
|
+
send schema literals offered to the model (role offer): OpenAI `{"type": "function", "function": {"name"}}`,
|
|
19
|
+
Responses `{"type": "function", "name"}`, Anthropic `{"name", "input_schema"}`; from the function holding
|
|
20
|
+
them, or from each function passing the module constant as `tools=`; `bind_tools([...])`,
|
|
21
|
+
`create_react_agent(model, tools=[...])`, `AgentExecutor(tools=[...])`, `ToolNode([...])`
|
|
22
|
+
agent:<name> Agents SDK `Agent(name=..., tools=[...], handoffs=[...])` and agent constructors assigned to a module
|
|
23
|
+
name; OFFERS_TOOL -> its tools, HANDS_OFF_TO -> its handoffs; attrs.model
|
|
24
|
+
findings an agent loop dispatching by a runtime name (`globals()[name]`, `getattr(obj, name)`, `eval`) records
|
|
25
|
+
attrs.llm_dynamic_dispatch on the function instead of a guessed link
|
|
26
|
+
model config literal `model=` / `base_url=` of chat / responses / messages calls and model constructors ->
|
|
27
|
+
attrs.llm_calls on the calling function (facts for the external-system nodes of #40)
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import ast
|
|
32
|
+
import re
|
|
33
|
+
|
|
34
|
+
from ...core.model import EXACT, HEURISTIC, RESOLVED
|
|
35
|
+
from ...protocols import protocol_receive, protocol_send
|
|
36
|
+
|
|
37
|
+
LIBS = ("mcp", "fastmcp", "openai", "agents", "anthropic", "langchain", "langchain_core", "langchain_community",
|
|
38
|
+
"langchain_openai", "langchain_anthropic", "langgraph", "llama_index", "langchain_mcp_adapters")
|
|
39
|
+
MCP_SERVERS = ("FastMCP", "MCPServer", "Server")
|
|
40
|
+
TOOL_DECOS = {"tool": None, "function_tool": "openai-agents", "beta_tool": "anthropic", "beta_async_tool": "anthropic"}
|
|
41
|
+
TOOL_CTORS = {"from_function", "from_defaults", "Tool", "StructuredTool", "FunctionTool"}
|
|
42
|
+
OFFER_CALLS = {"Agent", "bind_tools", "create_react_agent", "create_agent", "AgentExecutor", "ToolNode", "create_tool_calling_agent",
|
|
43
|
+
"create_openai_tools_agent", "ReActAgent", "FunctionAgent", "OpenAIAgent", "AgentWorkflow"}
|
|
44
|
+
AGENT_CTORS = {"Agent", "create_react_agent", "create_agent", "AgentExecutor", "ReActAgent", "FunctionAgent", "OpenAIAgent"}
|
|
45
|
+
BASE_TOOLS = ("langchain_core.tools.BaseTool", "langchain_core.tools.base.BaseTool", "langchain.tools.BaseTool",
|
|
46
|
+
"langchain.tools.base.BaseTool", "langchain_community.tools.BaseTool")
|
|
47
|
+
CLIENT_CALLS = {"call_tool": "mcp_tool", "read_resource": "mcp_resource", "get_prompt": "mcp_prompt"}
|
|
48
|
+
NAME_VARS = {"name", "tool_name", "function_name", "fn_name", "func_name", "tool"}
|
|
49
|
+
LOOP_MARKS = {"tool_calls", "tool_call", "tool_use", "function_call", "tool_name", "tool_uses"}
|
|
50
|
+
REGISTRY_NAME = re.compile(r"tool|function|handler|dispatch|action", re.I)
|
|
51
|
+
MODEL_CALLS = {("completions", "create"): ("openai", "chat"), ("responses", "create"): ("openai", "responses"),
|
|
52
|
+
("messages", "create"): ("anthropic", "messages"), ("messages", "stream"): ("anthropic", "messages"),
|
|
53
|
+
("embeddings", "create"): ("openai", "embeddings")}
|
|
54
|
+
MODEL_CTORS = {"ChatOpenAI": "openai", "AzureChatOpenAI": "azure-openai", "ChatAnthropic": "anthropic", "OpenAI": "openai",
|
|
55
|
+
"AsyncOpenAI": "openai", "AzureOpenAI": "azure-openai", "Anthropic": "anthropic", "AsyncAnthropic": "anthropic",
|
|
56
|
+
"init_chat_model": None, "ChatOllama": "ollama", "OpenAIChatCompletionsModel": "openai"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _s(e):
|
|
60
|
+
return e.value if isinstance(e, ast.Constant) and isinstance(e.value, str) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _kw(call, *names):
|
|
64
|
+
for k in call.keywords:
|
|
65
|
+
if k.arg in names:
|
|
66
|
+
return k.value
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _last(e):
|
|
71
|
+
if isinstance(e, ast.Subscript): # Agent[Context](...)
|
|
72
|
+
e = e.value
|
|
73
|
+
return e.attr if isinstance(e, ast.Attribute) else e.id if isinstance(e, ast.Name) else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _head(m, e):
|
|
77
|
+
"""Top-level package a name / attribute chain is imported from."""
|
|
78
|
+
while isinstance(e, (ast.Attribute, ast.Subscript)):
|
|
79
|
+
e = e.value
|
|
80
|
+
if isinstance(e, ast.Call):
|
|
81
|
+
return _head(m, e.func)
|
|
82
|
+
if not isinstance(e, ast.Name):
|
|
83
|
+
return None
|
|
84
|
+
imp = m.imports.get(e.id)
|
|
85
|
+
if not imp or not isinstance(imp, tuple) or len(imp) < 2 or not isinstance(imp[1], str):
|
|
86
|
+
return None
|
|
87
|
+
return imp[1].split(".")[0]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _framework(head):
|
|
91
|
+
return {"agents": "openai-agents", "llama_index": "llamaindex", "langgraph": "langgraph", "openai": "openai",
|
|
92
|
+
"anthropic": "anthropic", "mcp": "mcp", "fastmcp": "mcp"}.get(head, "langchain" if head and head.startswith("langchain") else head)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _first_line(doc):
|
|
96
|
+
return (doc or "").strip().splitlines()[0][:160] if (doc or "").strip() else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _params(fnode):
|
|
100
|
+
a = fnode.args
|
|
101
|
+
return [x.arg for x in a.posonlyargs + a.args + a.kwonlyargs if x.arg not in ("self", "cls", "ctx", "context")]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_test(path):
|
|
105
|
+
p = path.replace("\\", "/")
|
|
106
|
+
return any(s.startswith("test") for s in p.split("/")[:-1]) or p.rsplit("/", 1)[-1].startswith("test_") or p.endswith("_test.py")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def index(prog, b, walk_body, Ctx) -> dict:
|
|
110
|
+
mods = [m for m in prog.modules.values() if any(
|
|
111
|
+
isinstance(v, tuple) and len(v) > 1 and isinstance(v[1], str) and v[1].split(".")[0] in LIBS for v in m.imports.values())]
|
|
112
|
+
ai_mods = {m.name for m in mods}
|
|
113
|
+
st = {"mcp": {"tool": 0, "resource": 0, "prompt": 0, "client_calls": 0}, "llm_tools": 0, "offers": 0, "agents": 0,
|
|
114
|
+
"dynamic_dispatch": 0, "model_calls": 0}
|
|
115
|
+
regd = [n for n in b.nodes.values() if n.kind in ("function", "method") and any(
|
|
116
|
+
r.get("framework") == "mcp" for r in ((n.attrs or {}).get("registrations") or []))]
|
|
117
|
+
if not mods and not regd:
|
|
118
|
+
return {}
|
|
119
|
+
by_file = {m.file: m for m in prog.modules.values()}
|
|
120
|
+
fn_by_id = {f.id: f for f in prog.funcs.values()}
|
|
121
|
+
|
|
122
|
+
# ---- MCP server objects: (module, var) -> server name
|
|
123
|
+
servers: dict = {}
|
|
124
|
+
for m in prog.modules.values():
|
|
125
|
+
for name, vals in m.vars.items():
|
|
126
|
+
for v, _line, _a in vals:
|
|
127
|
+
if isinstance(v, ast.Call) and _last(v.func) in MCP_SERVERS and _head(m, v.func) in ("mcp", "fastmcp"):
|
|
128
|
+
sn = _s(v.args[0]) if v.args else None
|
|
129
|
+
servers[(m.name, name)] = sn or _s(_kw(v, "name")) or m.name
|
|
130
|
+
|
|
131
|
+
def server_of(m, var):
|
|
132
|
+
if (m.name, var) in servers:
|
|
133
|
+
return servers[(m.name, var)]
|
|
134
|
+
imp = m.imports.get(var)
|
|
135
|
+
if imp and imp[0] == "sym" and (imp[1], imp[2]) in servers:
|
|
136
|
+
return servers[(imp[1], imp[2])]
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
tool_of_func: dict = {} # FuncInfo.id -> endpoint id
|
|
140
|
+
tool_of_var: dict = {} # (module, var) -> endpoint id
|
|
141
|
+
tool_of_class: dict = {} # ClassInfo.id -> endpoint id
|
|
142
|
+
handlers: set = set()
|
|
143
|
+
|
|
144
|
+
def recv(protocol, name, f, file, line, conf, framework, **kw):
|
|
145
|
+
if hasattr(f, "id") and f.id not in b.nodes: # a nested def collapses into its owner: no handler node
|
|
146
|
+
return None
|
|
147
|
+
nid = protocol_receive(b, protocol, name, f.id if hasattr(f, "id") else f, file, line, conf, framework=framework,
|
|
148
|
+
node_attrs={"framework": framework, "declared_in": file, **kw})
|
|
149
|
+
if hasattr(f, "id"):
|
|
150
|
+
handlers.add(f.id)
|
|
151
|
+
return nid
|
|
152
|
+
|
|
153
|
+
# tools declared inside a function (servers built in a factory, tools defined in test bodies, #76): the nested def
|
|
154
|
+
# has no node of its own, so the endpoint is received by the enclosing function, with the nested name recorded
|
|
155
|
+
nested_owners: set = set()
|
|
156
|
+
local_servers: dict = {} # FuncInfo.id -> {local var: server name}
|
|
157
|
+
for f in prog.funcs.values():
|
|
158
|
+
if f.id not in b.nodes or f.module.name not in ai_mods:
|
|
159
|
+
continue
|
|
160
|
+
local: dict = {}
|
|
161
|
+
nested = []
|
|
162
|
+
for sub in ast.walk(f.node):
|
|
163
|
+
if sub is f.node:
|
|
164
|
+
continue
|
|
165
|
+
if isinstance(sub, ast.Assign) and isinstance(sub.value, ast.Call) and _last(sub.value.func) in MCP_SERVERS:
|
|
166
|
+
v = sub.value
|
|
167
|
+
if _head(f.module, v.func) in ("mcp", "fastmcp") or _last(v.func) in ("FastMCP", "MCPServer"):
|
|
168
|
+
sn = (_s(v.args[0]) if v.args else None) or _s(_kw(v, "name")) or f.name
|
|
169
|
+
for t in sub.targets:
|
|
170
|
+
if isinstance(t, ast.Name):
|
|
171
|
+
local[t.id] = sn
|
|
172
|
+
elif isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and sub.decorator_list:
|
|
173
|
+
nested.append(sub)
|
|
174
|
+
if local:
|
|
175
|
+
local_servers[f.id] = local
|
|
176
|
+
for fn in nested:
|
|
177
|
+
for d in fn.decorator_list:
|
|
178
|
+
de = d.func if isinstance(d, ast.Call) else d
|
|
179
|
+
if not (isinstance(de, ast.Attribute) and de.attr in ("tool", "resource", "prompt") and isinstance(de.value, ast.Name)):
|
|
180
|
+
continue
|
|
181
|
+
srv = local.get(de.value.id) or server_of(f.module, de.value.id)
|
|
182
|
+
if srv is None:
|
|
183
|
+
continue
|
|
184
|
+
call = d if isinstance(d, ast.Call) else None
|
|
185
|
+
pos = call.args if call is not None else []
|
|
186
|
+
if de.attr == "resource":
|
|
187
|
+
nm = ((_s(pos[0]) if pos else None) or _s(_kw(call, "uri"))) if call is not None else None
|
|
188
|
+
if not nm:
|
|
189
|
+
continue
|
|
190
|
+
else:
|
|
191
|
+
nm = ((_s(_kw(call, "name")) or (_s(pos[0]) if pos else None)) if call is not None else None) or fn.name
|
|
192
|
+
ok = protocol_receive(b, f"mcp_{de.attr}", f"{srv}/{nm}", f.id, f.file, d.lineno, EXACT, framework="mcp",
|
|
193
|
+
node_attrs={"framework": "mcp", "declared_in": f.file, "server": srv, "toolset": srv,
|
|
194
|
+
"description": _first_line(ast.get_docstring(fn)), "params": _params(fn),
|
|
195
|
+
"schema_source": "decorator", "nested_in": f.name, "handler_name": fn.name})
|
|
196
|
+
st["mcp"][de.attr] += bool(ok)
|
|
197
|
+
nested_owners.add(f.id)
|
|
198
|
+
# ---- 1. MCP server registrations (refs.py) -> endpoints
|
|
199
|
+
for n in regd:
|
|
200
|
+
f = fn_by_id.get(n.id)
|
|
201
|
+
if f is None:
|
|
202
|
+
continue
|
|
203
|
+
for r in n.attrs["registrations"]:
|
|
204
|
+
if r.get("framework") != "mcp" or (r.get("closure") and n.id in nested_owners): # read from the nested defs
|
|
205
|
+
continue
|
|
206
|
+
kind = r.get("registration") or "tool"
|
|
207
|
+
txt = r.get("decorator") or r.get("call") or ""
|
|
208
|
+
try:
|
|
209
|
+
e = ast.parse(txt, mode="eval").body
|
|
210
|
+
except SyntaxError:
|
|
211
|
+
e = None
|
|
212
|
+
call = e if isinstance(e, ast.Call) else None
|
|
213
|
+
if call is not None and isinstance(call.func, ast.Call): # mcp.tool()(fn)
|
|
214
|
+
call = call.func
|
|
215
|
+
var = None
|
|
216
|
+
if call is not None and isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name):
|
|
217
|
+
var = call.func.value.id
|
|
218
|
+
elif isinstance(e, ast.Attribute) and isinstance(e.value, ast.Name): # @mcp.tool without call
|
|
219
|
+
var, call = e.value.id, None
|
|
220
|
+
at_file = (r.get("at") or "").rsplit(":", 1)[0]
|
|
221
|
+
m = by_file.get(at_file) or f.module
|
|
222
|
+
srv = (server_of(m, var) if var else None) or m.name
|
|
223
|
+
pos = [a for a in (call.args if call is not None else [])]
|
|
224
|
+
if kind == "resource":
|
|
225
|
+
nm = (_s(pos[0]) if pos else None) or _s(_kw(call, "uri")) if call is not None else None
|
|
226
|
+
else:
|
|
227
|
+
nm = (_s(_kw(call, "name")) if call is not None else None) or (_s(pos[0]) if pos and kind != "resource" else None)
|
|
228
|
+
if kind == "resource" and not nm:
|
|
229
|
+
continue
|
|
230
|
+
nm = nm or f.name
|
|
231
|
+
line = f.line if r.get("registered_by") else int((r.get("at") or ":0").rsplit(":", 1)[-1] or 0) or f.line
|
|
232
|
+
ok = recv(f"mcp_{kind}", f"{srv}/{nm}", f, f.file if r.get("registered_by") else at_file or f.file, line, EXACT if not r.get("registered_by") else RESOLVED,
|
|
233
|
+
"mcp", server=srv, description=_first_line(ast.get_docstring(f.node)), params=_params(f.node),
|
|
234
|
+
schema_source="decorator", toolset=srv)
|
|
235
|
+
st["mcp"][kind if kind in st["mcp"] else "tool"] += bool(ok)
|
|
236
|
+
# decorators on a server object the registration pass could not type (the SDK's own examples: MCPServer is a
|
|
237
|
+
# local class there)
|
|
238
|
+
for f in prog.funcs.values():
|
|
239
|
+
if f.id in handlers:
|
|
240
|
+
continue
|
|
241
|
+
for d in f.decorators:
|
|
242
|
+
de = d.func if isinstance(d, ast.Call) else d
|
|
243
|
+
if not (isinstance(de, ast.Attribute) and de.attr in ("tool", "resource", "prompt") and isinstance(de.value, ast.Name)):
|
|
244
|
+
continue
|
|
245
|
+
srv = server_of(f.module, de.value.id)
|
|
246
|
+
if srv is None:
|
|
247
|
+
continue
|
|
248
|
+
call = d if isinstance(d, ast.Call) else None
|
|
249
|
+
pos = call.args if call is not None else []
|
|
250
|
+
if de.attr == "resource":
|
|
251
|
+
nm = ((_s(pos[0]) if pos else None) or _s(_kw(call, "uri"))) if call is not None else None
|
|
252
|
+
if not nm:
|
|
253
|
+
continue
|
|
254
|
+
else:
|
|
255
|
+
nm = ((_s(_kw(call, "name")) or (_s(pos[0]) if pos else None)) if call is not None else None) or f.name
|
|
256
|
+
ok = recv(f"mcp_{de.attr}", f"{srv}/{nm}", f, f.file, d.lineno, EXACT, "mcp", server=srv, toolset=srv,
|
|
257
|
+
description=_first_line(ast.get_docstring(f.node)), params=_params(f.node), schema_source="decorator")
|
|
258
|
+
st["mcp"][de.attr] += bool(ok)
|
|
259
|
+
# the registering decorator's reference to each handler was the only link before: the endpoint replaces it
|
|
260
|
+
drop = [k for k, e in b.edges.items() if e.kind == "REFERENCES_FN" and e.dst in handlers
|
|
261
|
+
and (e.attrs or {}).get("how") == "decorator"]
|
|
262
|
+
for k in drop:
|
|
263
|
+
del b.edges[k]
|
|
264
|
+
if not mods:
|
|
265
|
+
return st
|
|
266
|
+
|
|
267
|
+
def scopes():
|
|
268
|
+
for f in prog.funcs.values():
|
|
269
|
+
if f.module.name in ai_mods:
|
|
270
|
+
yield f.module, f, walk_body(f.node)
|
|
271
|
+
for m in mods:
|
|
272
|
+
yield m, None, (s for st_ in m.tree.body if not isinstance(st_, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
273
|
+
for s in ast.walk(st_))
|
|
274
|
+
|
|
275
|
+
# ---- 2. declared tools (decorators, constructors, BaseTool classes)
|
|
276
|
+
for f in prog.funcs.values():
|
|
277
|
+
m = f.module
|
|
278
|
+
if m.name not in ai_mods:
|
|
279
|
+
continue
|
|
280
|
+
for d in f.decorators:
|
|
281
|
+
de = d.func if isinstance(d, ast.Call) else d
|
|
282
|
+
last, head = _last(de), _head(m, de)
|
|
283
|
+
if last not in TOOL_DECOS or head not in LIBS or head in ("mcp", "fastmcp"):
|
|
284
|
+
continue
|
|
285
|
+
fw = TOOL_DECOS[last] or _framework(head)
|
|
286
|
+
nm = None
|
|
287
|
+
if isinstance(d, ast.Call):
|
|
288
|
+
nm = (_s(d.args[0]) if d.args else None) or _s(_kw(d, "name_override", "name", "name_or_callable"))
|
|
289
|
+
nid = recv("llm_tool", nm or f.name, f, f.file, d.lineno, EXACT, fw, description=_first_line(ast.get_docstring(f.node)),
|
|
290
|
+
params=_params(f.node), schema_source="decorator")
|
|
291
|
+
if nid:
|
|
292
|
+
tool_of_func[f.id] = nid
|
|
293
|
+
st["llm_tools"] += 1
|
|
294
|
+
for m, f, nodes in scopes():
|
|
295
|
+
ctx = Ctx(m, f, f.cls if f else None)
|
|
296
|
+
for sub in nodes:
|
|
297
|
+
if isinstance(sub, (ast.Assign, ast.AnnAssign)) and isinstance(sub.value, ast.Call):
|
|
298
|
+
c = sub.value
|
|
299
|
+
if _last(c.func) in TOOL_CTORS and _head(m, c.func) in LIBS and _head(m, c.func) not in ("mcp", "fastmcp"):
|
|
300
|
+
fnx = _kw(c, "func", "fn", "coroutine", "async_fn") or (c.args[0] if c.args and _last(c.func) in ("from_function", "from_defaults") else None)
|
|
301
|
+
t = prog.infer(fnx, ctx) if fnx is not None else None
|
|
302
|
+
h = t[1] if t and t[0] in ("func", "bound") else None
|
|
303
|
+
nm = _s(_kw(c, "name")) or (h.name if h else None)
|
|
304
|
+
if not nm:
|
|
305
|
+
continue
|
|
306
|
+
nid = recv("llm_tool", nm, h or (f.id if f else m.id), m.file, c.lineno, EXACT if h else RESOLVED,
|
|
307
|
+
_framework(_head(m, c.func)), description=_s(_kw(c, "description")),
|
|
308
|
+
schema_source="constructor")
|
|
309
|
+
if not nid:
|
|
310
|
+
continue
|
|
311
|
+
if h:
|
|
312
|
+
tool_of_func.setdefault(h.id, nid)
|
|
313
|
+
tgts = sub.targets if isinstance(sub, ast.Assign) else [sub.target]
|
|
314
|
+
for tg in tgts:
|
|
315
|
+
if isinstance(tg, ast.Name) and f is None:
|
|
316
|
+
tool_of_var[(m.name, tg.id)] = nid
|
|
317
|
+
st["llm_tools"] += 1
|
|
318
|
+
for c in prog.classes.values():
|
|
319
|
+
if c.module.name not in ai_mods or not prog.subclass_of(c, *BASE_TOOLS):
|
|
320
|
+
continue
|
|
321
|
+
nm, conf = None, EXACT
|
|
322
|
+
for s in c.node.body:
|
|
323
|
+
if isinstance(s, (ast.Assign, ast.AnnAssign)):
|
|
324
|
+
tg = s.targets[0] if isinstance(s, ast.Assign) else s.target
|
|
325
|
+
if isinstance(tg, ast.Name) and tg.id == "name":
|
|
326
|
+
nm = _s(s.value)
|
|
327
|
+
if not nm:
|
|
328
|
+
nm, conf = c.name, HEURISTIC
|
|
329
|
+
for mn in ("_run", "_arun"):
|
|
330
|
+
f = c.methods.get(mn)
|
|
331
|
+
if f is not None:
|
|
332
|
+
nid = recv("llm_tool", nm, f, f.file, f.line, conf, "langchain", schema_source="class attrs",
|
|
333
|
+
params=_params(f.node))
|
|
334
|
+
if nid:
|
|
335
|
+
tool_of_class[c.id] = nid
|
|
336
|
+
st["llm_tools"] += 1
|
|
337
|
+
|
|
338
|
+
# ---- 3. dict registries and agent-loop branches
|
|
339
|
+
registries: dict = {} # (module, var) -> [endpoint ids]
|
|
340
|
+
for m in mods:
|
|
341
|
+
for name, vals in m.vars.items():
|
|
342
|
+
if not REGISTRY_NAME.search(name):
|
|
343
|
+
continue
|
|
344
|
+
for v, line, _a in vals:
|
|
345
|
+
pairs = []
|
|
346
|
+
if isinstance(v, ast.Dict) and v.keys and all(_s(k) for k in v.keys):
|
|
347
|
+
pairs = [(_s(k), x) for k, x in zip(v.keys, v.values)]
|
|
348
|
+
elif isinstance(v, ast.DictComp) and isinstance(v.key, ast.Attribute) and v.key.attr == "__name__" \
|
|
349
|
+
and v.generators and isinstance(v.generators[0].iter, (ast.List, ast.Tuple)):
|
|
350
|
+
pairs = [(None, x) for x in v.generators[0].iter.elts]
|
|
351
|
+
ids = []
|
|
352
|
+
for k, x in pairs:
|
|
353
|
+
t = prog.infer(x, Ctx(m, None, None))
|
|
354
|
+
h = t[1] if t and t[0] in ("func", "bound") else None
|
|
355
|
+
if h is None:
|
|
356
|
+
continue
|
|
357
|
+
nid = recv("llm_tool", k or h.name, h, m.file, line, RESOLVED, "custom", via="dict registry",
|
|
358
|
+
schema_source="dict registry", toolset=f"{m.name}.{name}", params=_params(h.node))
|
|
359
|
+
if not nid:
|
|
360
|
+
continue
|
|
361
|
+
ids.append(nid)
|
|
362
|
+
tool_of_func.setdefault(h.id, nid)
|
|
363
|
+
st["llm_tools"] += 1
|
|
364
|
+
if ids:
|
|
365
|
+
registries[(m.name, name)] = ids
|
|
366
|
+
|
|
367
|
+
def nested_call_tool(f):
|
|
368
|
+
"""`@server.call_tool()` on a def nested in f (`async def serve(): server = Server(...)`, #76): server var."""
|
|
369
|
+
for sub in walk_body(f.node):
|
|
370
|
+
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
371
|
+
for d in sub.decorator_list:
|
|
372
|
+
de = d.func if isinstance(d, ast.Call) else d
|
|
373
|
+
if _last(de) == "call_tool" and isinstance(de, ast.Attribute) and isinstance(de.value, ast.Name):
|
|
374
|
+
return de.value.id
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
def loop_like(f):
|
|
378
|
+
for d in f.decorators:
|
|
379
|
+
de = d.func if isinstance(d, ast.Call) else d
|
|
380
|
+
if _last(de) == "call_tool":
|
|
381
|
+
return "mcp"
|
|
382
|
+
if f.id in local_servers and nested_call_tool(f):
|
|
383
|
+
return "mcp"
|
|
384
|
+
for sub in walk_body(f.node):
|
|
385
|
+
if isinstance(sub, ast.Attribute) and sub.attr in LOOP_MARKS:
|
|
386
|
+
return "llm"
|
|
387
|
+
if isinstance(sub, ast.Constant) and sub.value in ("tool_use", "function_call", "tool_calls"):
|
|
388
|
+
return "llm"
|
|
389
|
+
if LOOP_MARKS & set(_params(f.node)) or ("tool" in f.name.lower() and NAME_VARS & set(_params(f.node))):
|
|
390
|
+
return "llm"
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
def name_expr(e):
|
|
394
|
+
return (isinstance(e, ast.Name) and e.id in NAME_VARS) or (isinstance(e, ast.Attribute) and e.attr == "name")
|
|
395
|
+
|
|
396
|
+
def lit_of(m, e):
|
|
397
|
+
"""A string literal, or a `Cls.MEMBER` whose class body assigns it a string (`class GitTools(str, Enum)`, #76)."""
|
|
398
|
+
if _s(e):
|
|
399
|
+
return _s(e)
|
|
400
|
+
if not (isinstance(e, ast.Attribute) and isinstance(e.value, ast.Name)):
|
|
401
|
+
return None
|
|
402
|
+
c = m.classes.get(e.value.id)
|
|
403
|
+
imp = m.imports.get(e.value.id)
|
|
404
|
+
if c is None and isinstance(imp, tuple) and len(imp) > 2 and imp[0] == "sym":
|
|
405
|
+
c = prog.classes.get(f"{imp[1]}.{imp[2]}")
|
|
406
|
+
v = c.attrs.get(e.attr) if c is not None else None
|
|
407
|
+
return _s(v[0]) if v else None
|
|
408
|
+
|
|
409
|
+
def lowlevel_server(f):
|
|
410
|
+
for d in f.decorators:
|
|
411
|
+
de = d.func if isinstance(d, ast.Call) else d
|
|
412
|
+
if _last(de) == "call_tool" and isinstance(de, ast.Attribute) and isinstance(de.value, ast.Name):
|
|
413
|
+
return server_of(f.module, de.value.id) or f.module.name
|
|
414
|
+
var = nested_call_tool(f)
|
|
415
|
+
if var:
|
|
416
|
+
return local_servers.get(f.id, {}).get(var) or server_of(f.module, var) or f.module.name
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
for f in list(prog.funcs.values()):
|
|
420
|
+
if f.module.name not in ai_mods:
|
|
421
|
+
continue
|
|
422
|
+
kind = loop_like(f)
|
|
423
|
+
if not kind:
|
|
424
|
+
continue
|
|
425
|
+
ctx = Ctx(f.module, f, f.cls)
|
|
426
|
+
srv = lowlevel_server(f) if kind == "mcp" else None
|
|
427
|
+
branches = []
|
|
428
|
+
for sub in walk_body(f.node):
|
|
429
|
+
if isinstance(sub, ast.If) and isinstance(sub.test, ast.Compare) and len(sub.test.ops) == 1 \
|
|
430
|
+
and isinstance(sub.test.ops[0], ast.Eq):
|
|
431
|
+
l, r = sub.test.left, sub.test.comparators[0]
|
|
432
|
+
lit = lit_of(f.module, r) if name_expr(l) else lit_of(f.module, l) if name_expr(r) else None
|
|
433
|
+
if lit:
|
|
434
|
+
branches.append((lit, sub.body, sub.lineno, "if"))
|
|
435
|
+
elif isinstance(sub, ast.Match) and name_expr(sub.subject):
|
|
436
|
+
for case in sub.cases:
|
|
437
|
+
p = case.pattern
|
|
438
|
+
if isinstance(p, ast.MatchValue) and lit_of(f.module, p.value):
|
|
439
|
+
branches.append((lit_of(f.module, p.value), case.body, p.lineno, "match"))
|
|
440
|
+
elif isinstance(sub, ast.Subscript) and isinstance(sub.value, ast.Call) and _last(sub.value.func) in ("globals", "locals"):
|
|
441
|
+
_dynamic(b, f, sub, st)
|
|
442
|
+
elif isinstance(sub, ast.Call) and _last(sub.func) == "getattr" and len(sub.args) >= 2 and _dyn_name(sub.args[1]):
|
|
443
|
+
_dynamic(b, f, sub, st)
|
|
444
|
+
elif isinstance(sub, ast.Call) and _last(sub.func) == "eval":
|
|
445
|
+
_dynamic(b, f, sub, st)
|
|
446
|
+
for lit, body, line, how in branches:
|
|
447
|
+
h = None
|
|
448
|
+
for s in body:
|
|
449
|
+
for c in ast.walk(s):
|
|
450
|
+
if isinstance(c, ast.Call):
|
|
451
|
+
for tgt, _conf, _via in prog.resolve_call(c, ctx):
|
|
452
|
+
if hasattr(tgt, "node") and getattr(tgt, "kind", None) in ("function", "method"):
|
|
453
|
+
h = tgt
|
|
454
|
+
break
|
|
455
|
+
if h:
|
|
456
|
+
break
|
|
457
|
+
if h:
|
|
458
|
+
break
|
|
459
|
+
if srv:
|
|
460
|
+
recv("mcp_tool", f"{srv}/{lit}", h or f, f.file, line, RESOLVED, "mcp", server=srv, via=f"{how} name",
|
|
461
|
+
schema_source="low-level server", toolset=srv)
|
|
462
|
+
st["mcp"]["tool"] += 1
|
|
463
|
+
else:
|
|
464
|
+
nid = recv("llm_tool", lit, h or f, f.file, line, RESOLVED, "custom", via=f"{how} name",
|
|
465
|
+
schema_source="agent loop")
|
|
466
|
+
if h and nid:
|
|
467
|
+
tool_of_func.setdefault(h.id, nid)
|
|
468
|
+
st["llm_tools"] += bool(nid)
|
|
469
|
+
|
|
470
|
+
# ---- 4. senders: schema literals, offers, MCP client calls; agents; model config
|
|
471
|
+
schema_vars: dict = {} # (module, var) -> [(name, framework, line)]
|
|
472
|
+
|
|
473
|
+
def schema(d):
|
|
474
|
+
if not isinstance(d, ast.Dict):
|
|
475
|
+
return None
|
|
476
|
+
kv = {_s(k): v for k, v in zip(d.keys, d.values) if _s(k)}
|
|
477
|
+
if _s(kv.get("type")) == "function" and isinstance(kv.get("function"), ast.Dict):
|
|
478
|
+
inner = {_s(k): v for k, v in zip(kv["function"].keys, kv["function"].values) if _s(k)}
|
|
479
|
+
return (_s(inner.get("name")), "openai") if _s(inner.get("name")) else None
|
|
480
|
+
if _s(kv.get("type")) == "function" and _s(kv.get("name")) and ("parameters" in kv or "description" in kv):
|
|
481
|
+
return _s(kv["name"]), "openai"
|
|
482
|
+
if _s(kv.get("name")) and "input_schema" in kv:
|
|
483
|
+
return _s(kv["name"]), "anthropic"
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
for m in mods:
|
|
487
|
+
for name, vals in m.vars.items():
|
|
488
|
+
for v, line, _a in vals:
|
|
489
|
+
for sub in ast.walk(v):
|
|
490
|
+
sc = schema(sub)
|
|
491
|
+
if sc:
|
|
492
|
+
schema_vars.setdefault((m.name, name), []).append((*sc, sub.lineno))
|
|
493
|
+
|
|
494
|
+
def tools_in(e, ctx, depth=0):
|
|
495
|
+
"""Endpoint ids of the tools a `tools=[...]` value lists."""
|
|
496
|
+
out = []
|
|
497
|
+
if e is None or depth > 3:
|
|
498
|
+
return out
|
|
499
|
+
if isinstance(e, (ast.List, ast.Tuple, ast.Set)):
|
|
500
|
+
for x in e.elts:
|
|
501
|
+
out += tools_in(x, ctx, depth + 1)
|
|
502
|
+
return out
|
|
503
|
+
if isinstance(e, ast.Starred):
|
|
504
|
+
return tools_in(e.value, ctx, depth + 1)
|
|
505
|
+
if isinstance(e, ast.Call) and _last(e.func) not in ("list",):
|
|
506
|
+
t = prog.infer(e.func, ctx)
|
|
507
|
+
if t and t[0] == "type" and t[1].id in tool_of_class:
|
|
508
|
+
return [tool_of_class[t[1].id]]
|
|
509
|
+
return out
|
|
510
|
+
t = prog.infer(e, ctx)
|
|
511
|
+
if t and t[0] in ("func", "bound") and t[1].id in tool_of_func:
|
|
512
|
+
return [tool_of_func[t[1].id]]
|
|
513
|
+
if isinstance(e, ast.Name):
|
|
514
|
+
r = prog.resolve_name(ctx.mod, e.id)
|
|
515
|
+
if ctx.func is not None and e.id in prog.local_vars(ctx):
|
|
516
|
+
for lv in _local_values(ctx.func.node, e.id):
|
|
517
|
+
out += tools_in(lv, ctx, depth + 1)
|
|
518
|
+
return out
|
|
519
|
+
if r and r[0] == "var":
|
|
520
|
+
key = (r[1].name, r[2])
|
|
521
|
+
if key in tool_of_var:
|
|
522
|
+
return [tool_of_var[key]]
|
|
523
|
+
for v, _l, _a in r[1].vars.get(r[2], []):
|
|
524
|
+
out += tools_in(v, Ctx(r[1], None, None), depth + 1)
|
|
525
|
+
return out
|
|
526
|
+
|
|
527
|
+
def offer(m, f, ids, file, line, via, fw):
|
|
528
|
+
for nid in ids:
|
|
529
|
+
src = f.id if f else m.id
|
|
530
|
+
b.add_edge(src, nid, "SENDS_TO", file, line, RESOLVED, role="offer", via=via, framework=fw)
|
|
531
|
+
st["offers"] += 1
|
|
532
|
+
|
|
533
|
+
agent_vars: dict = {}
|
|
534
|
+
for m, f, nodes in scopes():
|
|
535
|
+
ctx = Ctx(m, f, f.cls if f else None)
|
|
536
|
+
test = _is_test(m.file)
|
|
537
|
+
assigned = {}
|
|
538
|
+
for sub in nodes:
|
|
539
|
+
if isinstance(sub, ast.Assign) and isinstance(sub.value, ast.Call) and len(sub.targets) == 1 \
|
|
540
|
+
and isinstance(sub.targets[0], ast.Name):
|
|
541
|
+
assigned[id(sub.value)] = sub.targets[0].id
|
|
542
|
+
for sub in (nodes if isinstance(nodes, list) else list(_rewalk(m, f, walk_body))):
|
|
543
|
+
if f is not None:
|
|
544
|
+
sc = schema(sub)
|
|
545
|
+
if sc:
|
|
546
|
+
protocol_send(b, "llm_tool", sc[0], f.id, m.file, sub.lineno, EXACT, test=test, role="offer",
|
|
547
|
+
framework=sc[1], schema_source="schema literal",
|
|
548
|
+
node_attrs={"framework": sc[1], "schema_source": "schema literal"})
|
|
549
|
+
st["offers"] += 1
|
|
550
|
+
if not isinstance(sub, ast.Call):
|
|
551
|
+
continue
|
|
552
|
+
last = _last(sub.func)
|
|
553
|
+
head = _head(m, sub.func)
|
|
554
|
+
tv = _kw(sub, "tools")
|
|
555
|
+
if f is not None and isinstance(tv, ast.Name):
|
|
556
|
+
r = prog.resolve_name(m, tv.id) if tv.id not in prog.local_vars(ctx) else None
|
|
557
|
+
if r and r[0] == "var" and (r[1].name, r[2]) in schema_vars:
|
|
558
|
+
for nm, fw, _l in schema_vars[(r[1].name, r[2])]:
|
|
559
|
+
protocol_send(b, "llm_tool", nm, f.id, m.file, sub.lineno, RESOLVED, test=test, role="offer",
|
|
560
|
+
framework=fw, schema_source="schema literal", via=f"tools={tv.id}",
|
|
561
|
+
node_attrs={"framework": fw, "schema_source": "schema literal"})
|
|
562
|
+
st["offers"] += 1
|
|
563
|
+
if last in CLIENT_CALLS and head is None and m.name in ai_mods:
|
|
564
|
+
nm = (_s(sub.args[0]) if sub.args else None) or _s(_kw(sub, "name", "uri"))
|
|
565
|
+
if nm and f is not None:
|
|
566
|
+
protocol_send(b, CLIENT_CALLS[last], f"*/{nm}", f.id, m.file, sub.lineno, EXACT, test=test, role="invoke",
|
|
567
|
+
framework="mcp")
|
|
568
|
+
st["mcp"]["client_calls"] += 1
|
|
569
|
+
continue
|
|
570
|
+
if last in OFFER_CALLS and (head in LIBS or (last == "bind_tools")):
|
|
571
|
+
lst = tv if tv is not None else (sub.args[0] if last in ("bind_tools", "ToolNode") and sub.args else
|
|
572
|
+
sub.args[1] if last in ("create_react_agent", "create_agent", "create_tool_calling_agent")
|
|
573
|
+
and len(sub.args) > 1 else None)
|
|
574
|
+
ids = tools_in(lst, ctx)
|
|
575
|
+
fw = _framework(head) or "langchain"
|
|
576
|
+
var = assigned.get(id(sub))
|
|
577
|
+
if last in AGENT_CTORS and (_s(_kw(sub, "name")) or (var and f is None)):
|
|
578
|
+
aname = _s(_kw(sub, "name")) or f"{m.name}.{var}"
|
|
579
|
+
aid = b.add_node("agent", aname, aname.rsplit(".", 1)[-1], fqn=aname, file=m.file, line=sub.lineno,
|
|
580
|
+
lang="python", attrs={"framework": fw, "model": _s(_kw(sub, "model")),
|
|
581
|
+
"tools": ids, "instructions_source": "literal" if _s(_kw(sub, "instructions")) else None})
|
|
582
|
+
for nid in ids:
|
|
583
|
+
b.add_edge(aid, nid, "OFFERS_TOOL", m.file, sub.lineno, RESOLVED)
|
|
584
|
+
offer(m, f, ids, m.file, sub.lineno, last, fw) # constructing the agent hands the tools to the model
|
|
585
|
+
b.add_edge(f.id if f else m.id, aid, "CONTAINS" if f is None else "INSTANTIATES", m.file, sub.lineno, EXACT)
|
|
586
|
+
if var and f is None:
|
|
587
|
+
agent_vars[(m.name, var)] = (aid, sub, ctx)
|
|
588
|
+
st["agents"] += 1
|
|
589
|
+
else:
|
|
590
|
+
offer(m, f, ids, m.file, sub.lineno, last, fw)
|
|
591
|
+
if last in MODEL_CTORS and head in LIBS or (isinstance(sub.func, ast.Attribute) and isinstance(sub.func.value, ast.Attribute)
|
|
592
|
+
and (sub.func.value.attr, sub.func.attr) in MODEL_CALLS):
|
|
593
|
+
mdl, base = _s(_kw(sub, "model", "model_name")), _s(_kw(sub, "base_url", "openai_api_base", "azure_endpoint"))
|
|
594
|
+
if mdl or base:
|
|
595
|
+
prov, op = MODEL_CALLS.get((sub.func.value.attr, sub.func.attr), (None, None)) if isinstance(sub.func, ast.Attribute) \
|
|
596
|
+
and isinstance(sub.func.value, ast.Attribute) else (MODEL_CTORS.get(last), "client")
|
|
597
|
+
owner = b.nodes.get(f.id if f else m.id)
|
|
598
|
+
if owner is not None:
|
|
599
|
+
if owner.attrs is None:
|
|
600
|
+
owner.attrs = {}
|
|
601
|
+
rec = {k: v for k, v in {"provider": prov or _framework(head), "op": op, "model": mdl, "base_url": base,
|
|
602
|
+
"line": sub.lineno}.items() if v}
|
|
603
|
+
if rec not in owner.attrs.setdefault("llm_calls", []):
|
|
604
|
+
owner.attrs["llm_calls"].append(rec)
|
|
605
|
+
st["model_calls"] += 1
|
|
606
|
+
for (mn, var), (aid, call, ctx) in agent_vars.items():
|
|
607
|
+
for h in (_kw(call, "handoffs").elts if isinstance(_kw(call, "handoffs"), (ast.List, ast.Tuple)) else []):
|
|
608
|
+
if isinstance(h, ast.Name):
|
|
609
|
+
r = prog.resolve_name(ctx.mod, h.id)
|
|
610
|
+
if r and r[0] == "var" and (r[1].name, r[2]) in agent_vars:
|
|
611
|
+
b.add_edge(aid, agent_vars[(r[1].name, r[2])][0], "HANDS_OFF_TO", ctx.mod.file, call.lineno, RESOLVED)
|
|
612
|
+
# module-level schema constants nobody passes as tools= are offered by their module
|
|
613
|
+
offered = {e.dst for e in b.edges.values() if e.kind in ("SENDS_TO", "TEST_CALLS") and e.dst.startswith("endpoint:llm_tool:")}
|
|
614
|
+
for (mn, var), lst in schema_vars.items():
|
|
615
|
+
m = prog.modules.get(mn)
|
|
616
|
+
for nm, fw, line in lst:
|
|
617
|
+
if f"endpoint:llm_tool:{nm}" not in offered and m is not None:
|
|
618
|
+
protocol_send(b, "llm_tool", nm, m.id, m.file, line, EXACT, test=_is_test(m.file), role="offer", framework=fw,
|
|
619
|
+
schema_source="schema literal", node_attrs={"framework": fw})
|
|
620
|
+
st["offers"] += 1
|
|
621
|
+
return {k: v for k, v in st.items() if v} if any(st["mcp"].values()) or any(v for k, v in st.items() if k != "mcp") else {}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _dyn_name(e) -> bool:
|
|
625
|
+
"""A runtime tool name: `name` / `tool_name` / ... or `<tool call>.name` (`tool_call.function.name`, `block.name`)."""
|
|
626
|
+
if isinstance(e, ast.JoinedStr):
|
|
627
|
+
return any(isinstance(v, ast.FormattedValue) and _dyn_name(v.value) for v in e.values)
|
|
628
|
+
if isinstance(e, ast.Name):
|
|
629
|
+
return e.id in NAME_VARS - {"tool"}
|
|
630
|
+
if isinstance(e, ast.Attribute) and e.attr == "name":
|
|
631
|
+
try:
|
|
632
|
+
base = ast.unparse(e.value).lower()
|
|
633
|
+
except Exception: # noqa: BLE001
|
|
634
|
+
return False
|
|
635
|
+
return any(w in base for w in ("tool", "function", "call", "block", "item"))
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _rewalk(m, f, walk_body):
|
|
640
|
+
if f is not None:
|
|
641
|
+
return walk_body(f.node)
|
|
642
|
+
return (s for st_ in m.tree.body if not isinstance(st_, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) for s in ast.walk(st_))
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _local_values(fnode, name):
|
|
646
|
+
for sub in ast.walk(fnode):
|
|
647
|
+
if isinstance(sub, ast.Assign) and any(isinstance(t, ast.Name) and t.id == name for t in sub.targets):
|
|
648
|
+
yield sub.value
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _dynamic(b, f, sub, st):
|
|
652
|
+
n = b.nodes.get(f.id)
|
|
653
|
+
if n is None:
|
|
654
|
+
return
|
|
655
|
+
if n.attrs is None:
|
|
656
|
+
n.attrs = {}
|
|
657
|
+
try:
|
|
658
|
+
expr = ast.unparse(sub)[:100]
|
|
659
|
+
except Exception: # noqa: BLE001
|
|
660
|
+
expr = "?"
|
|
661
|
+
rec = {"line": sub.lineno, "expr": expr}
|
|
662
|
+
if rec not in n.attrs.setdefault("llm_dynamic_dispatch", []):
|
|
663
|
+
n.attrs["llm_dynamic_dispatch"].append(rec)
|
|
664
|
+
st["dynamic_dispatch"] += 1
|