From 6b6d2c408a4e417b642346bda5280a338534cdca Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 10 Jul 2026 15:48:37 -0400 Subject: [PATCH 1/5] Add optional logging of vtk-mcp tool calls Add a "Log tool calls to the server console" toggle in the vtk-mcp settings (off by default, disabled without a server URL). When on, each tool call logs its name, arguments, and a short result at INFO level, and the loop logs when it hits the round cap without a final response. The flag threads from state through generate to the tool-calling loop; when off, behavior is unchanged. --- src/vtk_prompt/client.py | 16 +++++++++++++++- src/vtk_prompt/controllers/generation.py | 2 ++ src/vtk_prompt/state/initializer.py | 1 + src/vtk_prompt/ui/layout/settings_dialog.py | 9 +++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py index 3177d6c..4279933 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -296,6 +296,7 @@ def query( custom_prompt: dict | None = None, ui_mode: bool = False, execution_error: str | None = None, + log_tool_calls: bool = False, ) -> tuple[str, str, Any] | tuple[str, str, Any, list[str]] | str: """Generate VTK code using vtk-mcp tools when available. @@ -468,7 +469,15 @@ def query( except Exception: args = {} result = mcp_client.call_tool(tc.function.name, args) # type: ignore - logger.debug("Tool %s -> %s...", tc.function.name, result[:80]) + if log_tool_calls: + logger.info( + "vtk-mcp: %s(%s) -> %s", + tc.function.name, + tc.function.arguments, + result[:120], + ) + else: + logger.debug("Tool %s -> %s...", tc.function.name, result[:80]) self.conversation.append( {"role": "tool", "tool_call_id": tc.id, "content": result} ) @@ -480,6 +489,11 @@ def query( if content is None: # Tool loop exhausted without a text response + if log_tool_calls: + logger.info( + "vtk-mcp: tool loop hit the %d-round cap without a final response", + MAX_TOOL_ROUNDS, + ) if attempt == retry_attempts - 1: return ("No response generated", "", getattr(response, "usage", None) or {}) continue diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 576f0ed..8a872e6 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -103,6 +103,7 @@ async def generate_and_execute_code(app: Any) -> None: temperature=float(app.state.temperature), top_k=int(app.state.top_k), retry_attempts=int(app.state.retry_attempts), + log_tool_calls=bool(app.state.log_tool_calls), provider=app.state.provider, custom_prompt=app.custom_prompt_data, ui_mode=True, # This tells the client to use UI-specific components @@ -176,6 +177,7 @@ async def generate_and_execute_code(app: Any) -> None: temperature=float(app.state.temperature), top_k=int(app.state.top_k), retry_attempts=1, + log_tool_calls=bool(app.state.log_tool_calls), provider=app.state.provider, custom_prompt=app.custom_prompt_data, ui_mode=True, diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 74ae8b7..11208fb 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -51,6 +51,7 @@ def initialize_state(app: Any) -> None: app.state.code_history_pos = -1 app.state.is_loading = False app.state.mcp_url = "" + app.state.log_tool_calls = False # log vtk-mcp tool calls to the console app.state.error_message = "" app.state.input_tokens = 0 app.state.output_tokens = 0 diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index 48cbdf0..dbb565c 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -190,6 +190,15 @@ def _advanced_tab() -> None: hint="Context snippets retrieved per request", persistent_hint=True, ) + vuetify.VCheckbox( + label="Log tool calls to the server console", + v_model=("log_tool_calls", False), + density="compact", + color="primary", + disabled=("!mcp_url",), + hide_details=True, + classes="mt-2", + ) vuetify.VDivider(classes="my-5") _section("Generation") From 92b0aea11fac2711e4841f89546e157a28600d67 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 10 Jul 2026 17:02:18 -0400 Subject: [PATCH 2/5] Run tool calls that local backends emit as text Quantized local models (e.g. qwen2.5-coder via Ollama) often return a tool call as plain content instead of populating tool_calls, so finish_reason is "stop" and the agentic loop never ran the tool: tools appeared dead even though the model was trying to call them. Detect a tool call that arrived as text (a bare JSON object with name/arguments, or a block) whose name matches a known tool, execute it, and feed the result back like a native call. Answers, which are explanation/code tagged, are never misread as calls. --- src/vtk_prompt/client.py | 72 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py index 4279933..bd78df9 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -33,6 +33,40 @@ logger = get_logger(__name__) +def _parse_text_tool_calls(content: str | None, tool_names: set[str]) -> list[dict] | None: + """Extract tool calls a backend emitted as text instead of structured tool_calls. + + Some local OpenAI-compatible backends (e.g. Ollama with a quantized model) return + a tool call as plain content rather than populating ``tool_calls``. Handle both a + ``{...}`` block and a bare JSON object with ``name`` and + ``arguments``. Only objects whose name matches a known tool are treated as calls, + so normal tagged answers are never misread. Returns a list of {name, arguments}. + """ + if not content: + return None + candidates = re.findall(r"\s*(\{.*?\})\s*", content, re.DOTALL) + if not candidates: + stripped = content.strip() + if stripped.startswith("{") and stripped.endswith("}"): + candidates = [stripped] + calls: list[dict] = [] + for raw in candidates: + try: + obj = json.loads(raw) + except (ValueError, TypeError): + continue + name = obj.get("name") + args = obj.get("arguments", {}) + if isinstance(args, str): + try: + args = json.loads(args) + except (ValueError, TypeError): + args = {} + if name in tool_names and isinstance(args, dict): + calls.append({"name": name, "arguments": args}) + return calls or None + + @dataclass class VTKPromptClient: """OpenAI client for VTK code generation.""" @@ -418,6 +452,7 @@ def query( # Fetch vtk-mcp tools for LLM tool calling tools = mcp_client.list_tools() if mcp_client else [] + tool_names = {(t.get("function") or {}).get("name") for t in tools} # Retry loop for AST validation for attempt in range(retry_attempts): @@ -483,6 +518,43 @@ def query( ) continue # let LLM decide what to do next + # Fallback: the backend returned a tool call as plain text rather than + # in tool_calls (common with quantized local models). Run it anyway. + text_calls = _parse_text_tool_calls(choice.message.content, tool_names) + if tools and text_calls: + self.conversation.append( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": f"call_{i}", + "type": "function", + "function": { + "name": c["name"], + "arguments": json.dumps(c["arguments"]), + }, + } + for i, c in enumerate(text_calls) + ], + } + ) + for i, c in enumerate(text_calls): + result = mcp_client.call_tool(c["name"], c["arguments"]) # type: ignore + if log_tool_calls: + logger.info( + "vtk-mcp (text): %s(%s) -> %s", + c["name"], + json.dumps(c["arguments"]), + result[:120], + ) + else: + logger.debug("Tool %s (text) -> %s...", c["name"], result[:80]) + self.conversation.append( + {"role": "tool", "tool_call_id": f"call_{i}", "content": result} + ) + continue + # LLM generated a response (not a tool call) content = choice.message.content or "No content in response" break From 4360666ae0c9a518b068a86ac7753f1661dd29ff Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 10 Jul 2026 17:32:12 -0400 Subject: [PATCH 3/5] Persist the log-tool-calls setting in config log_tool_calls was session-only, so it reset to off every launch. Include it in the exported config and restore it on startup, alongside mcp_url/top_k. --- src/vtk_prompt/controllers/configuration.py | 2 ++ src/vtk_prompt/utils/prompt_loader.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/vtk_prompt/controllers/configuration.py b/src/vtk_prompt/controllers/configuration.py index 601841b..b0e1206 100644 --- a/src/vtk_prompt/controllers/configuration.py +++ b/src/vtk_prompt/controllers/configuration.py @@ -46,6 +46,7 @@ def save_config(app: Any) -> str: mcp_url = getattr(app.state, "mcp_url", "").strip() data_root = getattr(app.state, "data_root", "").strip() top_k = int(getattr(app.state, "top_k", 5)) + log_tool_calls = bool(getattr(app.state, "log_tool_calls", False)) base_url = getattr(app.state, "local_base_url", "").strip() if not use_cloud else "" content = { @@ -56,6 +57,7 @@ def save_config(app: Any) -> str: "mcp_url": mcp_url, "data_root": data_root, "top_k": top_k, + "log_tool_calls": log_tool_calls, "retries": retries, "modelParameters": { "temperature": temperature, diff --git a/src/vtk_prompt/utils/prompt_loader.py b/src/vtk_prompt/utils/prompt_loader.py index 668d89a..36382ab 100644 --- a/src/vtk_prompt/utils/prompt_loader.py +++ b/src/vtk_prompt/utils/prompt_loader.py @@ -120,6 +120,10 @@ def _process_rag_and_generation_settings(app: Any) -> None: _mcp = app.custom_prompt_data.get("mcp_url") if isinstance(_mcp, str): app.state.mcp_url = _mcp.strip() + if "log_tool_calls" in app.custom_prompt_data: + _ltc = app.custom_prompt_data.get("log_tool_calls") + if isinstance(_ltc, bool): + app.state.log_tool_calls = _ltc if "base_url" in app.custom_prompt_data: _base = app.custom_prompt_data.get("base_url") if isinstance(_base, str) and _base.strip(): From 16ba8bcb82babc9c38bd9077b411a2684a930e32 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Sat, 11 Jul 2026 10:49:23 -0400 Subject: [PATCH 4/5] Add an agentic-retrieval toggle (skip pre-injected context) Add a setting to skip get_enriched_context so the model works from tools instead of a large pre-injected RAG block (which also frees ~4.5k tokens of context). Threaded from state through generate; persisted in config; off by default. Note: on its own this does not make a confident quantized local model call tools under tool_choice="auto"; deterministic post-validation is the reliable path and is a separate change. --- src/vtk_prompt/client.py | 4 +++- src/vtk_prompt/controllers/configuration.py | 2 ++ src/vtk_prompt/controllers/generation.py | 2 ++ src/vtk_prompt/state/initializer.py | 1 + src/vtk_prompt/ui/layout/settings_dialog.py | 8 ++++++++ src/vtk_prompt/utils/prompt_loader.py | 4 ++++ 6 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py index bd78df9..ac2f20a 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -331,6 +331,7 @@ def query( ui_mode: bool = False, execution_error: str | None = None, log_tool_calls: bool = False, + agentic_retrieval: bool = False, ) -> tuple[str, str, Any] | tuple[str, str, Any, list[str]] | str: """Generate VTK code using vtk-mcp tools when available. @@ -394,7 +395,8 @@ def query( else: # Normal path: build context and prompt context_snippets = None - if mcp_client: + # Agentic mode: skip pre-injected context so the model must use tools. + if mcp_client and not agentic_retrieval: mcp_context = mcp_client.get_enriched_context(message, top_k=top_k) if mcp_context: context_snippets = mcp_context diff --git a/src/vtk_prompt/controllers/configuration.py b/src/vtk_prompt/controllers/configuration.py index b0e1206..a4e4433 100644 --- a/src/vtk_prompt/controllers/configuration.py +++ b/src/vtk_prompt/controllers/configuration.py @@ -47,6 +47,7 @@ def save_config(app: Any) -> str: data_root = getattr(app.state, "data_root", "").strip() top_k = int(getattr(app.state, "top_k", 5)) log_tool_calls = bool(getattr(app.state, "log_tool_calls", False)) + agentic_retrieval = bool(getattr(app.state, "agentic_retrieval", False)) base_url = getattr(app.state, "local_base_url", "").strip() if not use_cloud else "" content = { @@ -58,6 +59,7 @@ def save_config(app: Any) -> str: "data_root": data_root, "top_k": top_k, "log_tool_calls": log_tool_calls, + "agentic_retrieval": agentic_retrieval, "retries": retries, "modelParameters": { "temperature": temperature, diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 8a872e6..72e728f 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -104,6 +104,7 @@ async def generate_and_execute_code(app: Any) -> None: top_k=int(app.state.top_k), retry_attempts=int(app.state.retry_attempts), log_tool_calls=bool(app.state.log_tool_calls), + agentic_retrieval=bool(app.state.agentic_retrieval), provider=app.state.provider, custom_prompt=app.custom_prompt_data, ui_mode=True, # This tells the client to use UI-specific components @@ -178,6 +179,7 @@ async def generate_and_execute_code(app: Any) -> None: top_k=int(app.state.top_k), retry_attempts=1, log_tool_calls=bool(app.state.log_tool_calls), + agentic_retrieval=bool(app.state.agentic_retrieval), provider=app.state.provider, custom_prompt=app.custom_prompt_data, ui_mode=True, diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 11208fb..be26236 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -52,6 +52,7 @@ def initialize_state(app: Any) -> None: app.state.is_loading = False app.state.mcp_url = "" app.state.log_tool_calls = False # log vtk-mcp tool calls to the console + app.state.agentic_retrieval = False # skip pre-injected context; use tools app.state.error_message = "" app.state.input_tokens = 0 app.state.output_tokens = 0 diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index dbb565c..f7b1c88 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -199,6 +199,14 @@ def _advanced_tab() -> None: hide_details=True, classes="mt-2", ) + vuetify.VCheckbox( + label="Agentic retrieval (use tools instead of pre-injected context)", + v_model=("agentic_retrieval", False), + density="compact", + color="primary", + disabled=("!mcp_url",), + hide_details=True, + ) vuetify.VDivider(classes="my-5") _section("Generation") diff --git a/src/vtk_prompt/utils/prompt_loader.py b/src/vtk_prompt/utils/prompt_loader.py index 36382ab..8ab2480 100644 --- a/src/vtk_prompt/utils/prompt_loader.py +++ b/src/vtk_prompt/utils/prompt_loader.py @@ -124,6 +124,10 @@ def _process_rag_and_generation_settings(app: Any) -> None: _ltc = app.custom_prompt_data.get("log_tool_calls") if isinstance(_ltc, bool): app.state.log_tool_calls = _ltc + if "agentic_retrieval" in app.custom_prompt_data: + _ag = app.custom_prompt_data.get("agentic_retrieval") + if isinstance(_ag, bool): + app.state.agentic_retrieval = _ag if "base_url" in app.custom_prompt_data: _base = app.custom_prompt_data.get("base_url") if isinstance(_base, str) and _base.strip(): From 6d6e83775e50c374169136a1a43623fa982f6fe9 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Tue, 21 Jul 2026 14:44:16 -0400 Subject: [PATCH 5/5] Fix mypy errors in the text tool-call fallback tool_names was inferred as set[Any | None] from the tool schemas, and the synthesized assistant message tripped the dict[str, str] element type. Filter out missing names so the set is genuinely set[str], and build the message the same way the native tool-call path does. --- src/vtk_prompt/client.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py index ac2f20a..e395801 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -454,7 +454,11 @@ def query( # Fetch vtk-mcp tools for LLM tool calling tools = mcp_client.list_tools() if mcp_client else [] - tool_names = {(t.get("function") or {}).get("name") for t in tools} + tool_names: set[str] = { + str(name) + for t in tools + if (name := (t.get("function") or {}).get("name")) is not None + } # Retry loop for AST validation for attempt in range(retry_attempts): @@ -524,23 +528,19 @@ def query( # in tool_calls (common with quantized local models). Run it anyway. text_calls = _parse_text_tool_calls(choice.message.content, tool_names) if tools and text_calls: - self.conversation.append( + text_msg: dict = {"role": "assistant", "content": ""} + text_msg["tool_calls"] = [ { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": f"call_{i}", - "type": "function", - "function": { - "name": c["name"], - "arguments": json.dumps(c["arguments"]), - }, - } - for i, c in enumerate(text_calls) - ], + "id": f"call_{i}", + "type": "function", + "function": { + "name": c["name"], + "arguments": json.dumps(c["arguments"]), + }, } - ) + for i, c in enumerate(text_calls) + ] + self.conversation.append(text_msg) for i, c in enumerate(text_calls): result = mcp_client.call_tool(c["name"], c["arguments"]) # type: ignore if log_tool_calls: