know-do-graph 0.1.0__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 (63) hide show
  1. agents/__init__.py +0 -0
  2. agents/extraction_agent/__init__.py +0 -0
  3. agents/extraction_agent/agent.py +170 -0
  4. agents/graph_agent/__init__.py +5 -0
  5. agents/graph_agent/agent.py +373 -0
  6. agents/graph_agent/tools.py +2106 -0
  7. agents/maintenance_agent/__init__.py +0 -0
  8. agents/maintenance_agent/agent.py +283 -0
  9. agents/orchestrator/__init__.py +0 -0
  10. agents/orchestrator/agent.py +217 -0
  11. agents/review_agent/__init__.py +0 -0
  12. agents/review_agent/agent.py +188 -0
  13. agents/review_agent/tools.py +472 -0
  14. api/__init__.py +0 -0
  15. api/main.py +136 -0
  16. api/routes/__init__.py +0 -0
  17. api/routes/agent.py +81 -0
  18. api/routes/entries.py +411 -0
  19. api/routes/graph.py +132 -0
  20. api/routes/mem.py +179 -0
  21. api/routes/remote.py +815 -0
  22. api/routes/remote_sync.py +230 -0
  23. api/routes/retrieve.py +88 -0
  24. core/__init__.py +0 -0
  25. core/app_state.py +9 -0
  26. core/events.py +84 -0
  27. core/extraction/__init__.py +0 -0
  28. core/extraction/wikilink_parser.py +48 -0
  29. core/graph/__init__.py +0 -0
  30. core/graph/graph.py +204 -0
  31. core/memory/__init__.py +0 -0
  32. core/memory/memgraph.py +458 -0
  33. core/resources/starter.db +0 -0
  34. core/retrieval/__init__.py +0 -0
  35. core/retrieval/embedder.py +122 -0
  36. core/retrieval/fusion.py +52 -0
  37. core/retrieval/progressive.py +399 -0
  38. core/retrieval/retrieval.py +346 -0
  39. core/retrieval/vector_store.py +91 -0
  40. core/schemas/__init__.py +0 -0
  41. core/schemas/edge.py +46 -0
  42. core/schemas/entry.py +388 -0
  43. core/storage/__init__.py +0 -0
  44. core/storage/database.py +104 -0
  45. core/storage/models.py +66 -0
  46. core/storage/repository.py +243 -0
  47. core/sync/__init__.py +20 -0
  48. core/sync/autolink.py +301 -0
  49. core/sync/db_merge.py +297 -0
  50. core/sync/db_watcher.py +84 -0
  51. core/sync/remote_sync.py +345 -0
  52. examples/__init__.py +0 -0
  53. examples/example_entries.py +206 -0
  54. examples/pymatgen_interface_examples.py +811 -0
  55. frontend/dist/assets/index-BLfo7ZZu.css +1 -0
  56. frontend/dist/assets/index-G-mYbZ9R.js +83 -0
  57. frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
  58. frontend/dist/index.html +92 -0
  59. know_do_graph-0.1.0.dist-info/METADATA +765 -0
  60. know_do_graph-0.1.0.dist-info/RECORD +63 -0
  61. know_do_graph-0.1.0.dist-info/WHEEL +4 -0
  62. know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
  63. main.py +944 -0
agents/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,170 @@
1
+ """Extraction agent.
2
+
3
+ Reads source material (files, raw text) and populates the graph with
4
+ structured Entry objects. After insertion it can resolve [[wikilinks]]
5
+ to create typed Edge relations between entries.
6
+
7
+ Supported extraction meta-skills
8
+ ---------------------------------
9
+ * File reading/writing
10
+ * Wikilink parsing
11
+ * External reference extraction
12
+ * Entry creation
13
+ * Edge creation / dependency linking
14
+ * Source provenance tracking
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from core.extraction.wikilink_parser import (
23
+ extract_external_refs,
24
+ parse_wikilinks,
25
+ slug_from_title,
26
+ )
27
+ from core.graph.graph import KnowDoGraph
28
+ from core.schemas.edge import Edge, EdgeRelation
29
+ from core.schemas.entry import Entry, EntryMetadata, EntryType, RefinementStatus
30
+ from core.storage.database import SessionLocal
31
+ from core.storage.repository import EdgeRepository, EntryRepository
32
+
33
+ _TEXT_EXTENSIONS = {".md", ".txt", ".rst", ".yaml", ".yml", ".json"}
34
+
35
+
36
+ class ExtractionAgent:
37
+ """Reads source documents and extracts Entry objects into the graph.
38
+
39
+ Parameters
40
+ ----------
41
+ graph:
42
+ The shared in-process KnowDoGraph instance to keep in sync.
43
+ """
44
+
45
+ def __init__(self, graph: KnowDoGraph) -> None:
46
+ self._graph = graph
47
+
48
+ # ------------------------------------------------------------------
49
+ # Public extraction methods
50
+ # ------------------------------------------------------------------
51
+
52
+ def extract_from_file(
53
+ self,
54
+ path: Path,
55
+ entry_type: EntryType = EntryType.generic,
56
+ tags: Optional[list[str]] = None,
57
+ source_provenance: Optional[str] = None,
58
+ ) -> Entry:
59
+ """Create an Entry from a single text file."""
60
+ content = path.read_text(encoding="utf-8", errors="replace")
61
+ title = path.stem.replace("-", " ").replace("_", " ").title()
62
+ entry = Entry(
63
+ title=title,
64
+ slug=slug_from_title(title),
65
+ entry_type=entry_type,
66
+ content=content,
67
+ tags=tags or [],
68
+ metadata=EntryMetadata(
69
+ source_provenance=source_provenance or str(path),
70
+ extraction_method="file_read",
71
+ refinement_status=RefinementStatus.raw,
72
+ external_refs=extract_external_refs(content),
73
+ ),
74
+ )
75
+ return self._persist_entry(entry)
76
+
77
+ def extract_from_directory(
78
+ self,
79
+ directory: Path,
80
+ entry_type: EntryType = EntryType.generic,
81
+ tags: Optional[list[str]] = None,
82
+ recursive: bool = True,
83
+ ) -> list[Entry]:
84
+ """Extract entries from all text files in *directory*."""
85
+ glob = directory.rglob("*") if recursive else directory.glob("*")
86
+ files = [
87
+ f
88
+ for f in glob
89
+ if f.is_file() and f.suffix.lower() in _TEXT_EXTENSIONS
90
+ ]
91
+ return [
92
+ self.extract_from_file(f, entry_type=entry_type, tags=tags)
93
+ for f in files
94
+ ]
95
+
96
+ def extract_from_text(
97
+ self,
98
+ title: str,
99
+ content: str,
100
+ entry_type: EntryType = EntryType.generic,
101
+ tags: Optional[list[str]] = None,
102
+ source_provenance: Optional[str] = None,
103
+ ) -> Entry:
104
+ """Create an Entry from raw text."""
105
+ entry = Entry(
106
+ title=title,
107
+ slug=slug_from_title(title),
108
+ entry_type=entry_type,
109
+ content=content,
110
+ tags=tags or [],
111
+ metadata=EntryMetadata(
112
+ source_provenance=source_provenance,
113
+ extraction_method="text_input",
114
+ refinement_status=RefinementStatus.raw,
115
+ external_refs=extract_external_refs(content),
116
+ ),
117
+ )
118
+ return self._persist_entry(entry)
119
+
120
+ # ------------------------------------------------------------------
121
+ # Wikilink resolution
122
+ # ------------------------------------------------------------------
123
+
124
+ def resolve_wikilinks(self) -> int:
125
+ """Resolve all [[wikilinks]] across entries and create edges.
126
+
127
+ Returns the number of new edges created.
128
+ """
129
+ created = 0
130
+ with SessionLocal() as db:
131
+ entry_repo = EntryRepository(db)
132
+ edge_repo = EdgeRepository(db)
133
+ all_entries = entry_repo.get_all()
134
+ slug_map = {e.slug: e.id for e in all_entries}
135
+ title_map = {e.title.lower(): e.id for e in all_entries}
136
+ alias_map: dict[str, str] = {}
137
+ for e in all_entries:
138
+ for a in e.aliases:
139
+ alias_map.setdefault(a.lower(), e.id)
140
+
141
+ for entry in all_entries:
142
+ for ref in entry.internal_refs:
143
+ ref_slug = slug_from_title(ref)
144
+ ref_lower = ref.lower()
145
+ target_id = (
146
+ slug_map.get(ref_slug)
147
+ or title_map.get(ref_lower)
148
+ or alias_map.get(ref_lower)
149
+ )
150
+ if target_id and target_id != entry.id:
151
+ edge = Edge(
152
+ source_id=entry.id,
153
+ target_id=target_id,
154
+ relation=EdgeRelation.wikilink,
155
+ )
156
+ saved = edge_repo.create(edge)
157
+ self._graph.add_edge(saved)
158
+ created += 1
159
+ return created
160
+
161
+ # ------------------------------------------------------------------
162
+ # Internal helpers
163
+ # ------------------------------------------------------------------
164
+
165
+ def _persist_entry(self, entry: Entry) -> Entry:
166
+ with SessionLocal() as db:
167
+ repo = EntryRepository(db)
168
+ saved = repo.create(entry)
169
+ self._graph.add_entry(saved)
170
+ return saved
@@ -0,0 +1,5 @@
1
+ """agents.graph_agent package."""
2
+
3
+ from agents.graph_agent.agent import GraphAgent
4
+
5
+ __all__ = ["GraphAgent"]
@@ -0,0 +1,373 @@
1
+ """GraphAgent — LLM-driven agent for know-do graph management.
2
+
3
+ Uses the OpenAI function-calling API (compatible with any OpenAI-compatible
4
+ endpoint, e.g. Alibaba Cloud DashScope) to let a language model manipulate the
5
+ graph through structured tool calls.
6
+
7
+ Configuration is read from environment variables:
8
+ OPENAI_API_KEY — API key (required)
9
+ OPENAI_API_BASE — base URL override (optional, defaults to OpenAI)
10
+ GRAPH_AGENT_MODEL — model name (optional, defaults to openai/glm-5.1)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from typing import Any, Callable, Iterator
18
+
19
+ from openai import OpenAI
20
+
21
+ from core import events as _events
22
+ from core.graph.graph import KnowDoGraph
23
+ from agents.graph_agent.tools import TOOL_DISPATCH, TOOL_SCHEMAS
24
+
25
+ _DEFAULT_MODEL = "qwen-plus"
26
+
27
+ # Tool calls that mutate the graph; the frontend is notified via SSE so it
28
+ # can refresh after each such call.
29
+ _MUTATING_TOOLS: set[str] = {
30
+ "create_entry",
31
+ "update_entry",
32
+ "delete_entry",
33
+ "create_edge",
34
+ "delete_edge",
35
+ "merge_entries",
36
+ "resolve_wikilinks",
37
+ "remove_dangling_edges",
38
+ "create_script_entry",
39
+ "add_script_to_entry",
40
+ "attach_script_to_entry",
41
+ "add_asset_to_entry",
42
+ "build_material_interface_workflow",
43
+ "create_material_entry",
44
+ "submit_feedback",
45
+ "create_heuristic",
46
+ "create_constraint",
47
+ "decompose_capability",
48
+ }
49
+
50
+ # Read-only tools exposed when the agent is instantiated in query-only mode.
51
+ # Mutating tools are excluded so external agents can query the graph without
52
+ # accidentally writing nodes or edges.
53
+ _READ_ONLY_TOOLS: set[str] = {
54
+ "get_entry",
55
+ "search_entries",
56
+ "list_entries",
57
+ "get_neighbors",
58
+ "graph_stats",
59
+ "fetch_url",
60
+ "web_search",
61
+ "find_similar_nodes",
62
+ "get_graph_overview",
63
+ "list_nodes_by_type",
64
+ "get_script",
65
+ "list_scripts",
66
+ "list_assets",
67
+ "list_by_verification",
68
+ "list_needs_generalization",
69
+ "retrieve_plan",
70
+ "retrieve_heuristics",
71
+ "retrieve_constraints",
72
+ }
73
+
74
+ _READ_ONLY_SYSTEM_PROMPT = """You are a read-only knowledge-graph query assistant for the Know-Do Graph system.
75
+
76
+ Your role is to **answer questions** about the graph — searching, retrieving, and
77
+ summarising existing entries and relationships. You do NOT add, modify, or delete
78
+ any nodes or edges.
79
+
80
+ ## Search strategy
81
+ Use ``search_entries`` or ``find_similar_nodes`` for free-text queries.
82
+ Use ``get_entry`` to fetch full details of a specific node.
83
+ Use ``get_neighbors`` to explore relationships.
84
+ Use ``get_graph_overview`` to orient yourself when asked general questions.
85
+ Use ``list_nodes_by_type`` to enumerate nodes of a given category.
86
+
87
+ ## Important
88
+ - Do NOT attempt to create, update, delete, or merge any entries or edges.
89
+ - Do NOT call any write operations; only read/query tools are available.
90
+ - Summarise and return what exists in the graph as clearly as possible.
91
+ """
92
+
93
+ _SYSTEM_PROMPT = """You are an expert knowledge-graph management assistant for the Know-Do Graph system.
94
+
95
+ The graph stores structured *entries* (nodes) and typed *edges* between them.
96
+ Entries can represent capabilities, procedures, tools, workflows, dependencies,
97
+ scripts, materials, material interfaces, and more.
98
+
99
+ ## Node naming conventions
100
+ - Titles must be short, canonical, and human-readable (3–7 words preferred).
101
+ - Do NOT embed abbreviations or acronyms inside parentheses in the title (e.g. avoid
102
+ "Density Functional Theory (DFT)"). Instead put the acronym in `aliases`.
103
+ - Tags must be lowercase, hyphenated, and domain-specific.
104
+
105
+ ## Abstraction rule (CRITICAL — read carefully)
106
+ Skill nodes should describe a **reusable capability**, not a single concrete
107
+ instance. Concrete instances belong in the `content` (as examples) or as a
108
+ parameter, NOT as their own node.
109
+
110
+ BAD: "Build H2O molecule", "Build CH4 molecule", "Build NH3 molecule"
111
+ → three near-identical nodes that pollute the graph.
112
+ GOOD: One node "Build molecule from formula" whose content explains the
113
+ general procedure and lists examples (H2O, CH4, NH3).
114
+
115
+ BAD: "TiO2/SrTiO3 Interface", "MgO/Fe Interface", "GaN/AlN Interface"
116
+ → one node per material pair.
117
+ GOOD: One "Material interface construction" capability node + one
118
+ "Slab-stacking procedure" node, parameterised over material formulas.
119
+
120
+ Exception: a specific instance is worth its own node ONLY when (a) it has
121
+ unique constraints/data not derivable from the general procedure, OR (b) it
122
+ is a famous/canonical reference that other procedures cite.
123
+
124
+ Before calling `create_entry`:
125
+ 1. Call `find_similar_nodes` with both the specific title AND a generalised
126
+ version (e.g. for "Build H2O", also search "build molecule").
127
+ 2. If a generic match exists, do NOT create a new node — either link to the
128
+ existing one or extend its content with the new example.
129
+ 3. If no generic match exists, ask yourself: "Could a sibling node for a
130
+ different parameter value exist?" If yes, create the **generic** node, not
131
+ the specific one.
132
+
133
+ `create_entry` will set a `needs_generalization` flag on any node whose title
134
+ overlaps an existing one — treat that as a signal to merge or rename.
135
+
136
+ ## Entry types
137
+ - **capability** – what a system/tool can do; also used for material interfaces, known constructs, and runnable scripts (when `script_language` is set in metadata).
138
+ - **procedure** – step-by-step instructions.
139
+ - **workflow** – higher-level sequence linking multiple procedures.
140
+ - **tool** – software library, CLI tool, API, or instrument.
141
+ - **repository** – code repository or data repository.
142
+ - **environment** – computational or lab environment.
143
+ - **dependency** – package, library, or external service required by others.
144
+ - **data** – dataset, structural file, computed result, or reference material (crystals, compounds).
145
+ - **analytical** – analysis method or metric.
146
+ - **memory** – operational memory trace.
147
+ - **heuristic** – L3 conditional, empirical guidance attached to a skill (see hierarchical memory below).
148
+ - **constraint** – L4 known failure mode or limitation attached to a skill.
149
+ - **generic** – catch-all for entries that do not fit above.
150
+
151
+ ## Hierarchical memory (L1–L4) — progressive disclosure
152
+ The graph is organised into four orthogonal levels so planners can pull only
153
+ the level of detail they need:
154
+
155
+ - **L1 — Capability** (`capability` / `workflow`)
156
+ Reusable high-level ability. Planner-friendly. Stays domain-agnostic
157
+ when possible. Example: "construct amorphous structures".
158
+ - **L2 — Procedure** (`procedure`)
159
+ Executable workflow decomposition / tool sequencing. Example:
160
+ "initialize random structure → anneal → controlled quench → relax".
161
+ - **L3 — Heuristic** (`heuristic`)
162
+ Operational experience: conditional, empirical guidance. NOT a universal
163
+ truth. Example: "cooling rate strongly affects sp2/sp3 ratio".
164
+ - **L4 — Constraint / Failure Mode** (`constraint`)
165
+ Known limitation or failure pattern. Example: "unsuitable for
166
+ bond-breaking processes". Verifier-guided debugging starts here.
167
+
168
+ **Critical rules**
169
+ 1. Do NOT embed heuristics or failure modes inside a capability's `content`
170
+ blob. Create them as separate L3 / L4 nodes via `create_heuristic` /
171
+ `create_constraint` so progressive retrieval can surface them on demand.
172
+ 2. Do NOT encode domain-specific details directly into L1 capability names.
173
+ Prefer "construct amorphous structures" over "construct amorphous carbon
174
+ via Tersoff melt-quench". System-specific knowledge lives in L3/L4.
175
+ 3. When you create an L2 procedure that implements an existing L1 capability,
176
+ wire the link with `decompose_capability(capability, procedure)`.
177
+ 4. For retrieval, prefer the staged tools:
178
+ - `retrieve_plan(goal)` for planning (returns L1 + L2 only).
179
+ - `retrieve_heuristics(skill)` once a candidate is chosen.
180
+ - `retrieve_constraints(skill)` when the verifier reports an issue or you
181
+ need to estimate execution risk.
182
+ This avoids polluting the planning context with the full knowledge dump.
183
+
184
+ ## Verification & feedback
185
+ Every node carries `verification_status` (unverified | self_tested |
186
+ peer_reviewed | community_tested | bugged | deprecated). New nodes default to
187
+ `unverified`. When you or an external agent confirms a node works (or fails),
188
+ call `submit_feedback` with a verdict — this is how the graph self-evolves.
189
+
190
+ ## Script workflow
191
+ Scripts are **capability** entries with `script_language` set in metadata.
192
+ 1. Use ``create_script_entry`` to add runnable scripts.
193
+ 2. Link scripts to procedures/capabilities via ``attach_script_to_entry``.
194
+ 3. Any entry with `script_language` set can be downloaded at ``GET /entries/{id}/download``.
195
+
196
+ ## Node assets (folder-style)
197
+ Every node behaves like a small folder containing typed assets, addressable as
198
+ `[entry]/[folder]/[filename]` and served at
199
+ ``GET /entries/{id}/assets/{folder}/{filename}``.
200
+
201
+ Conventional folders (free-form names also allowed):
202
+ - ``scripts`` — runnable code (Python/bash/…)
203
+ - ``references`` — URLs to papers, repos, docs (use ``kind="link"``)
204
+ - ``docs`` — markdown/text documentation (``kind="text"``)
205
+ - ``examples`` — example input files, configs, notebooks
206
+ - ``data`` — small datasets / structural files
207
+ - ``notes`` — free-form annotations
208
+
209
+ Use ``add_asset_to_entry`` for anything beyond a script (URL, doc, example file).
210
+ Use ``add_script_to_entry`` for runnable scripts (auto-targets the ``scripts`` folder).
211
+ Use ``list_assets`` to inspect a node's folder tree.
212
+
213
+ ## Search strategy
214
+ Both ``search_entries`` and ``find_similar_nodes`` support three modes:
215
+ - **hybrid** (default) — fuses embedding vector similarity (ANN) with keyword scoring via
216
+ Reciprocal Rank Fusion, then re-ranks by verification trust and usage count. Best general-purpose choice.
217
+ - **semantic** — embedding-only. Use when the exact words differ but the concept is the same
218
+ (paraphrases, synonyms, related domains). Good for catching near-duplicates with different wording.
219
+ - **keyword** — exact text matching on title, aliases, tags, content. Use for known acronyms,
220
+ formula strings, or when you need precise title lookup.
221
+
222
+ If an initial search returns poor results, try a different mode or rephrase/broaden the query
223
+ before assuming no match exists. For duplicate detection, run at least one semantic pass.
224
+
225
+ ## Workflow for adding new knowledge
226
+ 1. Call ``get_graph_overview`` to orient yourself.
227
+ 2. For every concept you intend to create, search for both the specific and
228
+ generalised name with ``find_similar_nodes``.
229
+ 3. Choose the most appropriate ``entry_type``; write clean lowercase
230
+ hyphenated tags; put abbreviations in ``aliases``.
231
+ 4. Wire meaningful typed edges. Do not leave nodes isolated.
232
+ 5. Resolve wikilinks when done.
233
+
234
+ ## Workflow for restructuring / cleaning
235
+ - Use ``find_similar_nodes`` to detect near-duplicates before merging.
236
+ - Use ``merge_entries`` to consolidate duplicates.
237
+ - Use ``list_needs_generalization`` to find nodes flagged as too specific.
238
+ - Fix titles that contain parenthetical acronyms by moving the acronym to aliases.
239
+
240
+ Always confirm actions taken and briefly summarise what you did.
241
+ """
242
+
243
+
244
+ class GraphAgent:
245
+ """LLM-powered agent that manipulates the Know-Do Graph via tool calls.
246
+
247
+ Parameters
248
+ ----------
249
+ graph:
250
+ The shared ``KnowDoGraph`` instance.
251
+ model:
252
+ Model identifier forwarded to the OpenAI client.
253
+ """
254
+
255
+ def __init__(
256
+ self,
257
+ graph: KnowDoGraph,
258
+ model: str | None = None,
259
+ on_step: Callable[[str, dict], None] | None = None,
260
+ read_only: bool = False,
261
+ ) -> None:
262
+ self._graph = graph
263
+ self._model = model or os.environ.get("GRAPH_AGENT_MODEL", _DEFAULT_MODEL)
264
+ self._client = OpenAI(
265
+ api_key=os.environ["OPENAI_API_KEY"],
266
+ base_url=os.environ.get("OPENAI_API_BASE"),
267
+ )
268
+ self._read_only = read_only
269
+ system_prompt = _READ_ONLY_SYSTEM_PROMPT if read_only else _SYSTEM_PROMPT
270
+ self._history: list[dict] = [{"role": "system", "content": system_prompt}]
271
+ self._on_step = on_step
272
+ # Filter tool schemas to read-only set when in query-only mode
273
+ self._tool_schemas = (
274
+ [s for s in TOOL_SCHEMAS if s["function"]["name"] in _READ_ONLY_TOOLS]
275
+ if read_only
276
+ else TOOL_SCHEMAS
277
+ )
278
+
279
+ # ------------------------------------------------------------------
280
+ # Public interface
281
+ # ------------------------------------------------------------------
282
+
283
+ def chat(self, user_message: str) -> str:
284
+ """Send one turn and return the final assistant text response."""
285
+ self._history.append({"role": "user", "content": user_message})
286
+ response_text = self._run_loop()
287
+ self._history.append({"role": "assistant", "content": response_text})
288
+ return response_text
289
+
290
+ def reset(self) -> None:
291
+ """Clear conversation history (keeps system prompt)."""
292
+ self._history = [self._history[0]]
293
+
294
+ # ------------------------------------------------------------------
295
+ # Internal agentic loop
296
+ # ------------------------------------------------------------------
297
+
298
+ def _run_loop(self) -> str:
299
+ """Run the tool-call loop until the model produces a final reply."""
300
+ MAX_ITERATIONS = 20
301
+ for i in range(MAX_ITERATIONS):
302
+ if self._on_step:
303
+ self._on_step("thinking", {"iteration": i + 1})
304
+
305
+ response = self._client.chat.completions.create(
306
+ model=self._model,
307
+ messages=self._history,
308
+ tools=self._tool_schemas,
309
+ tool_choice="auto",
310
+ )
311
+ message = response.choices[0].message
312
+
313
+ # No tool calls — model is done
314
+ if not message.tool_calls:
315
+ return message.content or ""
316
+
317
+ # Append assistant message with tool_calls
318
+ self._history.append(message.model_dump(exclude_unset=True))
319
+
320
+ # Execute each tool call and collect results
321
+ for tc in message.tool_calls:
322
+ try:
323
+ display_args = {k: v for k, v in json.loads(tc.function.arguments or "{}").items() if k != "graph"}
324
+ except Exception:
325
+ display_args = {}
326
+ if self._on_step:
327
+ self._on_step("tool_call", {"name": tc.function.name, "args": display_args})
328
+
329
+ result = self._dispatch(tc.function.name, tc.function.arguments)
330
+
331
+ if self._on_step:
332
+ self._on_step("tool_result", {"name": tc.function.name, "result": result})
333
+
334
+ self._history.append(
335
+ {
336
+ "role": "tool",
337
+ "tool_call_id": tc.id,
338
+ "content": json.dumps(result, default=str),
339
+ }
340
+ )
341
+
342
+ return "Agent reached maximum iterations without a final answer."
343
+
344
+ def _dispatch(self, name: str, arguments_json: str) -> Any:
345
+ """Call the named tool with the provided JSON arguments."""
346
+ # Guard: in read-only mode, reject any mutating tool that somehow slips through
347
+ if self._read_only and name in _MUTATING_TOOLS:
348
+ return {"error": f"Tool '{name}' is not available in read-only mode."}
349
+
350
+ func = TOOL_DISPATCH.get(name)
351
+ if func is None:
352
+ return {"error": f"Unknown tool: {name}"}
353
+ try:
354
+ kwargs = json.loads(arguments_json) if arguments_json else {}
355
+ except json.JSONDecodeError as exc:
356
+ return {"error": f"Bad arguments JSON: {exc}"}
357
+
358
+ # Inject the live graph instance into every call
359
+ kwargs["graph"] = self._graph
360
+ try:
361
+ result = func(**kwargs)
362
+ except Exception as exc: # noqa: BLE001
363
+ return {"error": str(exc)}
364
+
365
+ # Broadcast a refresh hint so connected frontends re-fetch the graph.
366
+ if name in _MUTATING_TOOLS:
367
+ is_error = isinstance(result, dict) and "error" in result
368
+ if not is_error:
369
+ try:
370
+ _events.emit("graph_changed", {"tool": name})
371
+ except Exception:
372
+ pass
373
+ return result