mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-10-03 19:45:41 +00:00
kcpp agent mcp tool calling works
This commit is contained in:
parent
d2a2223e34
commit
f9880f4669
2 changed files with 176 additions and 14 deletions
166
kcpp_agent.py
166
kcpp_agent.py
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
"""A tiny, cross-platform OpenAI Chat Completions-compatible local agent, for use in KoboldCpp.
|
||||
|
||||
Eight tools:
|
||||
Eight built-in tools, plus tools exposed by KoboldCpp's MCP proxy:
|
||||
- read
|
||||
- write
|
||||
- edit
|
||||
|
|
@ -157,7 +157,7 @@ class Throbber:
|
|||
|
||||
def system_prompt() -> str:
|
||||
return f"""You are a small, careful local computer assistant running on {platform.system()}.
|
||||
You have eight tools: read, write, edit, shell, list_directory, glob, grep, and web_fetch.
|
||||
You have eight built-in tools: read, write, edit, shell, list_directory, glob, grep, and web_fetch. The server may also supply MCP tools.
|
||||
|
||||
Rules:
|
||||
- Use tools when needed instead of pretending an action happened.
|
||||
|
|
@ -858,6 +858,7 @@ def chat_completion(
|
|||
api_key: str,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
temperature: float,
|
||||
max_tokens: int | None,
|
||||
request_timeout: int,
|
||||
|
|
@ -867,7 +868,7 @@ def chat_completion(
|
|||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": TOOLS,
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
|
@ -924,6 +925,117 @@ def api_url(base_url: str, resource: str) -> str:
|
|||
return urllib.parse.urlunsplit(parsed._replace(path=path))
|
||||
|
||||
|
||||
def mcp_url(base_url: str) -> str:
|
||||
"""Return the KoboldCpp MCP proxy URL for an OpenAI-compatible base URL."""
|
||||
parsed = urllib.parse.urlsplit(normalize_base_url(base_url))
|
||||
return urllib.parse.urlunsplit(parsed._replace(path="/mcp", query="", fragment=""))
|
||||
|
||||
|
||||
def mcp_request(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
method: str,
|
||||
params: dict[str, Any],
|
||||
timeout: int,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
mcp_url(base_url),
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"MCP HTTP {exc.code}: {limit_text(body, 'error response')}") from exc
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError) as exc:
|
||||
raise RuntimeError(f"Could not reach KoboldCpp MCP proxy: {exc}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"KoboldCpp MCP proxy returned invalid JSON: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError("KoboldCpp MCP proxy returned a non-object response")
|
||||
if value.get("error") is not None:
|
||||
raise RuntimeError(f"MCP error: {json.dumps(value['error'], ensure_ascii=False)}")
|
||||
return value
|
||||
|
||||
|
||||
def discover_mcp_tools(
|
||||
base_url: str, api_key: str, timeout: int
|
||||
) -> tuple[list[dict[str, Any]], set[str], list[str]]:
|
||||
response = mcp_request(base_url, api_key, "tools/list", {}, timeout)
|
||||
result = response.get("result", {})
|
||||
raw_tools = result.get("tools", []) if isinstance(result, dict) else []
|
||||
if not isinstance(raw_tools, list):
|
||||
raise RuntimeError("MCP tools/list result does not contain a tools list")
|
||||
|
||||
tools: list[dict[str, Any]] = []
|
||||
names: set[str] = set()
|
||||
warnings: list[str] = []
|
||||
reserved = set(TOOL_IMPL)
|
||||
for item in raw_tools:
|
||||
if not isinstance(item, dict):
|
||||
warnings.append("Skipped a malformed MCP tool entry")
|
||||
continue
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", name):
|
||||
warnings.append(f"Skipped MCP tool with unsupported name: {name!r}")
|
||||
continue
|
||||
if name in reserved or name in names:
|
||||
warnings.append(f"Skipped conflicting MCP tool name: {name}")
|
||||
continue
|
||||
description = item.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
description = str(description)
|
||||
parameters = item.get("inputSchema", {"type": "object"})
|
||||
if not isinstance(parameters, dict):
|
||||
warnings.append(f"Skipped MCP tool with invalid input schema: {name}")
|
||||
continue
|
||||
tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": f"[MCP] {description}".strip(),
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
)
|
||||
names.add(name)
|
||||
return tools, names, warnings
|
||||
|
||||
|
||||
def call_mcp_tool(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
timeout: int,
|
||||
) -> str:
|
||||
response = mcp_request(
|
||||
base_url,
|
||||
api_key,
|
||||
"tools/call",
|
||||
{"name": name, "arguments": arguments},
|
||||
timeout,
|
||||
)
|
||||
result = response.get("result")
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
def probe_endpoint(base_url: str, api_key: str, timeout: int) -> tuple[bool, str]:
|
||||
"""Check reachability without spending tokens on a completion."""
|
||||
request = urllib.request.Request(
|
||||
|
|
@ -987,7 +1099,7 @@ def print_runtime_help(
|
|||
print(
|
||||
"\n" + color("Runtime commands:", ANSI_BOLD_CYAN) + "\n"
|
||||
" /help Show this help\n"
|
||||
" /clear Clear conversation history\n"
|
||||
" /clear Clear history and refresh MCP tools\n"
|
||||
" /confirm Show confirmation status\n"
|
||||
" /confirm on Require approval for every tool call\n"
|
||||
" /confirm off Auto-approve tool calls\n"
|
||||
|
|
@ -1042,6 +1154,27 @@ def run_agent(
|
|||
return
|
||||
base_url = replacement
|
||||
|
||||
available_tools = list(TOOLS)
|
||||
mcp_tool_names: set[str] = set()
|
||||
|
||||
def refresh_mcp_tools() -> None:
|
||||
nonlocal available_tools, mcp_tool_names
|
||||
available_tools = list(TOOLS)
|
||||
mcp_tool_names = set()
|
||||
try:
|
||||
mcp_tools, mcp_tool_names, warnings = discover_mcp_tools(
|
||||
base_url, api_key, request_timeout
|
||||
)
|
||||
available_tools.extend(mcp_tools)
|
||||
for warning in warnings:
|
||||
print(color("MCP warning:", ANSI_YELLOW) + f" {warning}")
|
||||
if mcp_tools:
|
||||
print(color("MCP tools:", ANSI_CYAN) + f" {len(mcp_tools)} loaded")
|
||||
except Exception as exc:
|
||||
print(color("MCP unavailable:", ANSI_YELLOW) + f" {exc}")
|
||||
|
||||
refresh_mcp_tools()
|
||||
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt()}
|
||||
]
|
||||
|
|
@ -1076,6 +1209,7 @@ def run_agent(
|
|||
continue
|
||||
if command == "/clear" and not command_arg:
|
||||
messages[:] = [{"role": "system", "content": system_prompt()}]
|
||||
refresh_mcp_tools()
|
||||
print("Conversation cleared.\n")
|
||||
continue
|
||||
if command == "/confirm":
|
||||
|
|
@ -1136,12 +1270,14 @@ def run_agent(
|
|||
if reachable:
|
||||
base_url = candidate
|
||||
print(f"Connected to {base_url} ({detail}).\n")
|
||||
refresh_mcp_tools()
|
||||
continue
|
||||
replacement = prompt_for_endpoint(
|
||||
candidate, api_key, request_timeout, detail
|
||||
)
|
||||
if replacement is not None:
|
||||
base_url = replacement
|
||||
refresh_mcp_tools()
|
||||
continue
|
||||
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
|
|
@ -1155,6 +1291,7 @@ def run_agent(
|
|||
api_key=api_key,
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=available_tools,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
request_timeout=request_timeout,
|
||||
|
|
@ -1168,6 +1305,7 @@ def run_agent(
|
|||
if replacement is None:
|
||||
break
|
||||
base_url = replacement
|
||||
refresh_mcp_tools()
|
||||
continue
|
||||
except APIResponseError as exc:
|
||||
label = color("API error:", ANSI_RED, stderr=True)
|
||||
|
|
@ -1232,6 +1370,7 @@ def run_agent(
|
|||
call_id = call.get("id", "tool_call")
|
||||
function = call.get("function") or {}
|
||||
name = function.get("name", "")
|
||||
display_name = f"MCP: {name}" if name in mcp_tool_names else name
|
||||
raw_args = function.get("arguments", "{}")
|
||||
|
||||
try:
|
||||
|
|
@ -1241,13 +1380,22 @@ def run_agent(
|
|||
except Exception as exc:
|
||||
result = f"ERROR: invalid tool arguments: {exc}"
|
||||
else:
|
||||
if name not in TOOL_IMPL:
|
||||
if name not in TOOL_IMPL and name not in mcp_tool_names:
|
||||
result = f"ERROR: unknown tool: {name}"
|
||||
elif not confirm_tool_call(name, args, auto_approve, verbose):
|
||||
elif not confirm_tool_call(display_name, args, auto_approve, verbose):
|
||||
result = "DENIED BY USER: The user did not approve this tool call."
|
||||
else:
|
||||
try:
|
||||
result = TOOL_IMPL[name](args)
|
||||
if name in mcp_tool_names:
|
||||
result = call_mcp_tool(
|
||||
base_url,
|
||||
api_key,
|
||||
name,
|
||||
args,
|
||||
request_timeout,
|
||||
)
|
||||
else:
|
||||
result = TOOL_IMPL[name](args)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = "ERROR: shell command timed out"
|
||||
except Exception as exc:
|
||||
|
|
@ -1257,7 +1405,7 @@ def run_agent(
|
|||
# listings and any future tools that forget to limit themselves.
|
||||
result = limit_text(str(result), "tool result")
|
||||
|
||||
print_tool_result(name, result, verbose)
|
||||
print_tool_result(display_name, result, verbose)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
|
|
@ -1279,7 +1427,7 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.add_argument(
|
||||
"--api-key",
|
||||
default=DEFAULT_API_KEY,
|
||||
help="API key (default: OPENAI_API_KEY or 'local')",
|
||||
help="API key for model requests and the KoboldCpp MCP proxy (default: OPENAI_API_KEY or 'local')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
|
|
|
|||
24
koboldcpp.py
24
koboldcpp.py
|
|
@ -8584,7 +8584,7 @@ def save_config_dict(filename, savdict, template):
|
|||
filenamestr += ".kcpps"
|
||||
if not filenamestr.endswith(".kcppt") and template:
|
||||
filenamestr += ".kcppt"
|
||||
do_not_save = {'agent_base_url', 'allow_config_onready', 'analyze', 'config', 'exportconfig', 'exporttemplate', 'run_agent', 'testmemory', 'unpack', 'version'}
|
||||
do_not_save = {'agent_api_key', 'agent_base_url', 'allow_config_onready', 'analyze', 'config', 'exportconfig', 'exporttemplate', 'run_agent', 'testmemory', 'unpack', 'version'}
|
||||
filtered = {k: v for k, v in savdict.items() if k not in do_not_save}
|
||||
if 'gendefaults' in filtered:
|
||||
gendefaults = parse_json_object(filtered['gendefaults'], 'gendefaults')
|
||||
|
|
@ -11601,7 +11601,7 @@ def get_kobold_agent_path():
|
|||
return os.path.join(base_path, "kcpp_agent.py")
|
||||
|
||||
|
||||
def run_kobold_agent(base_url=None):
|
||||
def run_kobold_agent(base_url=None, api_key=None):
|
||||
agent_path = get_kobold_agent_path()
|
||||
if not os.path.isfile(agent_path):
|
||||
raise FileNotFoundError(f"Kobold Agent script not found: {agent_path}")
|
||||
|
|
@ -11614,12 +11614,14 @@ def run_kobold_agent(base_url=None):
|
|||
sys.argv = [agent_path]
|
||||
if base_url:
|
||||
sys.argv.extend(["--base-url", base_url])
|
||||
if api_key:
|
||||
sys.argv.extend(["--api-key", api_key])
|
||||
runpy.run_path(agent_path, run_name="__main__")
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
def launch_kobold_agent(base_url=None):
|
||||
def launch_kobold_agent(base_url=None, api_key=None):
|
||||
agent_path = get_kobold_agent_path()
|
||||
if not os.path.isfile(agent_path):
|
||||
print(f"Cannot launch Kobold Agent: script not found at {agent_path}")
|
||||
|
|
@ -11634,6 +11636,11 @@ def launch_kobold_agent(base_url=None):
|
|||
command.extend(["--agent-base-url", base_url])
|
||||
else:
|
||||
command.extend(["--base-url", base_url])
|
||||
if api_key:
|
||||
if getattr(sys, 'frozen', False):
|
||||
command.extend(["--agent-api-key", api_key])
|
||||
else:
|
||||
command.extend(["--api-key", api_key])
|
||||
|
||||
try:
|
||||
if os.name == 'nt':
|
||||
|
|
@ -11811,7 +11818,7 @@ def main(launch_args, default_args):
|
|||
args = launch_args #note: these are NOT shared with the child processes!
|
||||
|
||||
if args.run_agent:
|
||||
run_kobold_agent(args.agent_base_url)
|
||||
run_kobold_agent(args.agent_base_url, args.agent_api_key)
|
||||
return
|
||||
|
||||
if args.agent and len(sys.argv) == 2:
|
||||
|
|
@ -13277,7 +13284,13 @@ def kcpp_main_process(launch_args, g_memory=None, gui_launcher=False):
|
|||
on_server_ready = None
|
||||
if agent_base_url:
|
||||
def on_server_ready():
|
||||
return launch_kobold_agent(agent_base_url)
|
||||
if args.mcpfile:
|
||||
agent_timer = threading.Timer(
|
||||
2, launch_kobold_agent, args=(agent_base_url, args.password)
|
||||
)
|
||||
agent_timer.start()
|
||||
return True
|
||||
return launch_kobold_agent(agent_base_url, args.password)
|
||||
asyncio.run(RunServerMultiThreaded(args.host, args.port, KcppServerRequestHandler, on_server_ready))
|
||||
else:
|
||||
# Flush stdout for previous win32 issue so the client can see output.
|
||||
|
|
@ -13507,5 +13520,6 @@ if __name__ == '__main__':
|
|||
debuggroup.add_argument("--testmemory", help=argparse.SUPPRESS, action='store_true')
|
||||
debuggroup.add_argument("--run-agent", help=argparse.SUPPRESS, action='store_true')
|
||||
debuggroup.add_argument("--agent-base-url", help=argparse.SUPPRESS, default=None)
|
||||
debuggroup.add_argument("--agent-api-key", help=argparse.SUPPRESS, default=None)
|
||||
|
||||
main(launch_args=parser.parse_args(),default_args=parser.parse_args([]))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue