coderio 0.4.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.
- coderio/__init__.py +14 -0
- coderio/agent/__init__.py +5 -0
- coderio/agent/_deepagents_compat.py +139 -0
- coderio/agent/command_review.py +140 -0
- coderio/agent/deep_loop.py +758 -0
- coderio/agent/harness.py +768 -0
- coderio/agent/harness_middleware.py +293 -0
- coderio/agent/permission_middleware.py +52 -0
- coderio/agent/prompts.py +304 -0
- coderio/agent/skill_tool.py +70 -0
- coderio/agent/state.py +144 -0
- coderio/agent/stream.py +93 -0
- coderio/cli/__init__.py +0 -0
- coderio/cli/app.py +257 -0
- coderio/cli/commands.py +339 -0
- coderio/cli/credentials.py +70 -0
- coderio/cli/mcp_cmd.py +227 -0
- coderio/cli/multimodal.py +94 -0
- coderio/cli/onboarding.py +270 -0
- coderio/cli/providers.py +113 -0
- coderio/cli/render.py +28 -0
- coderio/cli/repl.py +189 -0
- coderio/cli/run_cmd.py +171 -0
- coderio/cli/skills_cmd.py +77 -0
- coderio/cli/stream.py +224 -0
- coderio/cli/tui.py +1584 -0
- coderio/cli/tui_onboarding.py +465 -0
- coderio/cli/tui_screens.py +334 -0
- coderio/cli/tui_widgets.py +453 -0
- coderio/config/__init__.py +21 -0
- coderio/config/bootstrap.py +38 -0
- coderio/config/loader.py +273 -0
- coderio/config/models.py +193 -0
- coderio/config/trust.py +136 -0
- coderio/llm/__init__.py +3 -0
- coderio/llm/factory.py +164 -0
- coderio/llm/probe.py +153 -0
- coderio/mcp_loader.py +274 -0
- coderio/session/__init__.py +3 -0
- coderio/session/message.py +79 -0
- coderio/session/store.py +244 -0
- coderio/skills/__init__.py +4 -0
- coderio/skills/bundled/executing-plans/SKILL.md +15 -0
- coderio/skills/lion-skills/LICENSE +21 -0
- coderio/skills/lion-skills/README.md +114 -0
- coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
- coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
- coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
- coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
- coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
- coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
- coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
- coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
- coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
- coderio/skills/models.py +66 -0
- coderio/skills/parser.py +50 -0
- coderio/skills/store.py +102 -0
- coderio/tools/__init__.py +94 -0
- coderio/tools/base.py +55 -0
- coderio/tools/bash.py +168 -0
- coderio/tools/command_policy.py +364 -0
- coderio/tools/edit_file.py +55 -0
- coderio/tools/glob_tool.py +21 -0
- coderio/tools/grep_tool.py +90 -0
- coderio/tools/linux_sandbox.py +196 -0
- coderio/tools/list_dir.py +58 -0
- coderio/tools/multi_edit.py +62 -0
- coderio/tools/note.py +72 -0
- coderio/tools/permission.py +214 -0
- coderio/tools/read_file.py +51 -0
- coderio/tools/sandbox_runner.py +113 -0
- coderio/tools/todo.py +71 -0
- coderio/tools/web_fetch.py +188 -0
- coderio/tools/web_search.py +49 -0
- coderio/tools/win_job.py +214 -0
- coderio/tools/win_sandbox.py +639 -0
- coderio/tools/write_file.py +25 -0
- coderio-0.4.0.dist-info/METADATA +490 -0
- coderio-0.4.0.dist-info/RECORD +97 -0
- coderio-0.4.0.dist-info/WHEEL +4 -0
- coderio-0.4.0.dist-info/entry_points.txt +2 -0
- coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
coderio/llm/factory.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from langchain_anthropic import ChatAnthropic
|
|
7
|
+
from langchain_openai import ChatOpenAI
|
|
8
|
+
|
|
9
|
+
from coderio.config import Config
|
|
10
|
+
|
|
11
|
+
# Max SDK-level retries on transient failures (429 / 5xx / network). The OpenAI
|
|
12
|
+
# client defaults to 2 and Anthropic to 2; bump to 3 so a brief rate-limit spike
|
|
13
|
+
# or a momentary network blip doesn't immediately surface as a fatal error to
|
|
14
|
+
# the user. The SDK already backs off exponentially between attempts.
|
|
15
|
+
_MAX_RETRIES: int = 3
|
|
16
|
+
# Per-request timeout in seconds. Without an explicit value the SDK default
|
|
17
|
+
# applies (OpenAI: none/60s, Anthropic: 600s), which is inconsistent across
|
|
18
|
+
# providers and can hang the TUI on a stuck connection. 90s covers slow coding
|
|
19
|
+
# models while still failing fast on a dead endpoint.
|
|
20
|
+
_REQUEST_TIMEOUT: int = 90
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_client(kind: str, *, model: str, base_url: str, api_key, max_tokens: int):
|
|
24
|
+
"""Construct a ChatAnthropic or ChatOpenAI with uniform retry/timeout settings.
|
|
25
|
+
|
|
26
|
+
Centralizes the two knobs (max_retries, timeout) so every provider layer
|
|
27
|
+
(profile, registry provider_id, custom provider_id, S0 fallback) gets the
|
|
28
|
+
same resilience instead of each call site setting them ad hoc.
|
|
29
|
+
"""
|
|
30
|
+
common = dict(
|
|
31
|
+
model=model,
|
|
32
|
+
base_url=base_url,
|
|
33
|
+
api_key=api_key,
|
|
34
|
+
max_tokens=max_tokens,
|
|
35
|
+
max_retries=_MAX_RETRIES,
|
|
36
|
+
timeout=_REQUEST_TIMEOUT,
|
|
37
|
+
)
|
|
38
|
+
if kind == "anthropic":
|
|
39
|
+
return ChatAnthropic(**common)
|
|
40
|
+
return ChatOpenAI(**common)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pick_api_key(provider: str) -> str | None:
|
|
44
|
+
if provider == "anthropic":
|
|
45
|
+
return os.environ.get("ANTHROPIC_API_KEY")
|
|
46
|
+
return os.environ.get("Z_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_profile(cfg: Config):
|
|
50
|
+
"""Pick the Profile to build from, or None to fall through to the legacy path.
|
|
51
|
+
|
|
52
|
+
- active_profile set → find the matching Profile by name. If the name
|
|
53
|
+
doesn't match any profile (stale config), fall through to legacy.
|
|
54
|
+
- active_profile empty but profiles exist → default to the first one, so a
|
|
55
|
+
freshly-onboarded user with one profile and no explicit active_profile
|
|
56
|
+
still uses it.
|
|
57
|
+
- no profiles → None (legacy [model] path, unchanged behavior).
|
|
58
|
+
"""
|
|
59
|
+
profiles = cfg.profiles or []
|
|
60
|
+
if not profiles:
|
|
61
|
+
return None
|
|
62
|
+
if cfg.active_profile:
|
|
63
|
+
for p in profiles:
|
|
64
|
+
if p.name == cfg.active_profile:
|
|
65
|
+
return p
|
|
66
|
+
# Stale active_profile name — fall through rather than crash.
|
|
67
|
+
return None
|
|
68
|
+
return profiles[0]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_chat_model(cfg: Config, creds_path: Path | str | None = None):
|
|
72
|
+
"""Build a chat model.
|
|
73
|
+
|
|
74
|
+
Resolution order:
|
|
75
|
+
0. active_profile set → use that Profile's provider_id/model/base_url/kind
|
|
76
|
+
+ credentials key (named multi-config, the /profile path)
|
|
77
|
+
1. provider_id in registry → use registry base_url/kind + credentials key
|
|
78
|
+
2. provider_id set but NOT in registry → use config.toml's base_url/provider
|
|
79
|
+
+ credentials key (custom provider from config.toml or 'coderio config add')
|
|
80
|
+
3. no provider_id → S0 fallback: config.toml provider/base_url + env key
|
|
81
|
+
|
|
82
|
+
Layers 1-3 are the legacy single-config path. Layer 0 takes precedence when
|
|
83
|
+
the user has created named profiles via onboarding; without profiles,
|
|
84
|
+
cfg.active_profile is "" and layers 1-3 run unchanged.
|
|
85
|
+
"""
|
|
86
|
+
m = cfg.model
|
|
87
|
+
max_tokens = m.max_output_tokens
|
|
88
|
+
|
|
89
|
+
# Layer 0: named profile (multi-config). Takes precedence over [model].
|
|
90
|
+
profile = _resolve_profile(cfg)
|
|
91
|
+
if profile is not None:
|
|
92
|
+
from coderio.cli.credentials import get_key
|
|
93
|
+
from coderio.cli.providers import get_provider
|
|
94
|
+
|
|
95
|
+
info = get_provider(profile.provider_id)
|
|
96
|
+
key = get_key(profile.provider_id, creds_path) or _pick_api_key(info.kind if info else profile.kind)
|
|
97
|
+
model_name = profile.model or (info.default_model if info and info.default_model else "")
|
|
98
|
+
# Registry providers supply their own base_url/kind; custom profiles
|
|
99
|
+
# carry their own (mirrors the [model] layer 1 vs 2 split).
|
|
100
|
+
base_url = info.base_url if info and info.base_url else profile.base_url
|
|
101
|
+
kind = info.kind if info else profile.kind
|
|
102
|
+
if not base_url:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"profile '{profile.name}': provider_id '{profile.provider_id}' "
|
|
105
|
+
f"has no base_url. Set base_url in the profile or use a known provider_id."
|
|
106
|
+
)
|
|
107
|
+
return _build_client(
|
|
108
|
+
kind,
|
|
109
|
+
model=model_name,
|
|
110
|
+
base_url=base_url,
|
|
111
|
+
api_key=key,
|
|
112
|
+
max_tokens=max_tokens,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if m.provider_id:
|
|
116
|
+
from coderio.cli.credentials import get_key
|
|
117
|
+
from coderio.cli.providers import get_provider
|
|
118
|
+
|
|
119
|
+
info = get_provider(m.provider_id)
|
|
120
|
+
key = get_key(m.provider_id, creds_path) or _pick_api_key(info.kind if info else m.provider)
|
|
121
|
+
model_name = m.default or (info.default_model if info else "")
|
|
122
|
+
if info:
|
|
123
|
+
# Known provider — use registry base_url/kind.
|
|
124
|
+
return _build_client(
|
|
125
|
+
info.kind,
|
|
126
|
+
model=model_name,
|
|
127
|
+
base_url=info.base_url,
|
|
128
|
+
api_key=key,
|
|
129
|
+
max_tokens=max_tokens,
|
|
130
|
+
)
|
|
131
|
+
# Custom provider_id not in registry — use config.toml base_url/provider.
|
|
132
|
+
kind = m.provider or "openai_compatible"
|
|
133
|
+
if not m.base_url:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"provider_id '{m.provider_id}' is not a known provider and "
|
|
136
|
+
f"no base_url is set in config.toml. Either use a known provider_id, "
|
|
137
|
+
f"or set [model] base_url and provider in config.toml."
|
|
138
|
+
)
|
|
139
|
+
if kind == "anthropic":
|
|
140
|
+
return _build_client(
|
|
141
|
+
kind,
|
|
142
|
+
model=model_name,
|
|
143
|
+
base_url=m.base_url,
|
|
144
|
+
api_key=key,
|
|
145
|
+
max_tokens=max_tokens,
|
|
146
|
+
)
|
|
147
|
+
return _build_client(
|
|
148
|
+
kind,
|
|
149
|
+
model=model_name,
|
|
150
|
+
base_url=m.base_url,
|
|
151
|
+
api_key=key,
|
|
152
|
+
max_tokens=max_tokens,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
api_key = _pick_api_key(m.provider)
|
|
156
|
+
if m.provider in ("openai_compatible", "anthropic"):
|
|
157
|
+
return _build_client(
|
|
158
|
+
m.provider,
|
|
159
|
+
model=m.default,
|
|
160
|
+
base_url=m.base_url,
|
|
161
|
+
api_key=api_key,
|
|
162
|
+
max_tokens=max_tokens,
|
|
163
|
+
)
|
|
164
|
+
raise ValueError(f"Unknown provider: {m.provider!r} (expected 'openai_compatible' or 'anthropic')")
|
coderio/llm/probe.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Provider context-window discovery.
|
|
2
|
+
|
|
3
|
+
On setup, we want to record the actual model's context window so the context-
|
|
4
|
+
compaction threshold is accurate. coderio defaults to 200K (covers most modern
|
|
5
|
+
models), but a model like step-3.7-flash (256K) would be mistreated as 200K,
|
|
6
|
+
triggering compaction at 120K instead of 153K.
|
|
7
|
+
|
|
8
|
+
This module queries the provider's model-info endpoint once at onboarding time
|
|
9
|
+
and returns the context window size. On ANY failure (network, parse, auth,
|
|
10
|
+
shape mismatch), it returns 0 — the caller falls back to the config default.
|
|
11
|
+
No retry, no exception: this is a best-effort optimization, never a blocker
|
|
12
|
+
for the setup flow.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
# The fields different providers use to report context window size in their
|
|
22
|
+
# /v1/models/{id} (OpenAI-compatible) or /v1/models/{id} (Anthropic) responses.
|
|
23
|
+
# We check each in order and take the first that's a positive int.
|
|
24
|
+
_CONTEXT_FIELDS = (
|
|
25
|
+
"context_length",
|
|
26
|
+
"max_context_length",
|
|
27
|
+
"context_window",
|
|
28
|
+
"context_window_tokens",
|
|
29
|
+
"max_input_tokens",
|
|
30
|
+
"input_context_length",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def probe_context_limit(
|
|
35
|
+
provider_kind: str,
|
|
36
|
+
base_url: str,
|
|
37
|
+
api_key: str,
|
|
38
|
+
model: str,
|
|
39
|
+
timeout: float = 5.0,
|
|
40
|
+
) -> int:
|
|
41
|
+
"""Query the provider's model-info endpoint for the context window size.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
provider_kind: "anthropic" or "openai_compatible" (any other value
|
|
45
|
+
routes through the OpenAI-compatible path).
|
|
46
|
+
base_url: provider base URL (no trailing slash), e.g.
|
|
47
|
+
"https://api.stepfun.com/v1" or "https://api.anthropic.com".
|
|
48
|
+
api_key: API key for the Authorization header.
|
|
49
|
+
model: model id, e.g. "step-3.7-flash".
|
|
50
|
+
timeout: HTTP timeout in seconds. Default 5s — this runs during
|
|
51
|
+
onboarding and the user is waiting; a stuck probe shouldn't block.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The context window size in tokens (e.g. 256000), or 0 on any failure
|
|
55
|
+
(network error, auth error, unexpected response shape, missing field).
|
|
56
|
+
Callers MUST treat 0 as "unknown, use default".
|
|
57
|
+
"""
|
|
58
|
+
base = (base_url or "").rstrip("/")
|
|
59
|
+
if not base or not model:
|
|
60
|
+
return 0
|
|
61
|
+
try:
|
|
62
|
+
if provider_kind == "anthropic":
|
|
63
|
+
# Anthropic's GET /v1/models/{id} returns {"data": {"id": ..., ...}}
|
|
64
|
+
# and currently does NOT report context_window — but we probe anyway
|
|
65
|
+
# because the API surface may grow. Both header shapes accepted.
|
|
66
|
+
url = f"{base}/models/{urllib.parse.quote(model)}"
|
|
67
|
+
headers = (
|
|
68
|
+
"x-api-key",
|
|
69
|
+
api_key,
|
|
70
|
+
"anthropic-version",
|
|
71
|
+
"2023-06-01",
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
# OpenAI-compatible: GET /v1/models/{id} (note: many compatible
|
|
75
|
+
# providers like Stepfun/DeepSeek/Together expose context_length in
|
|
76
|
+
# the per-model response even though vanilla OpenAI doesn't).
|
|
77
|
+
# base_url may or may not include /v1; normalize to .../v1/models/...
|
|
78
|
+
if base.endswith("/v1"):
|
|
79
|
+
url = f"{base}/models/{urllib.parse.quote(model)}"
|
|
80
|
+
else:
|
|
81
|
+
url = f"{base}/v1/models/{urllib.parse.quote(model)}"
|
|
82
|
+
headers = ("Authorization", f"Bearer {api_key}")
|
|
83
|
+
req = urllib.request.Request(url, headers=_pair_to_dict(headers), method="GET")
|
|
84
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
85
|
+
if resp.status != 200:
|
|
86
|
+
return 0
|
|
87
|
+
payload = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
88
|
+
return _extract_context_limit(payload)
|
|
89
|
+
except (
|
|
90
|
+
urllib.error.URLError,
|
|
91
|
+
urllib.error.HTTPError,
|
|
92
|
+
TimeoutError,
|
|
93
|
+
ValueError,
|
|
94
|
+
json.JSONDecodeError,
|
|
95
|
+
OSError,
|
|
96
|
+
):
|
|
97
|
+
return 0
|
|
98
|
+
except Exception:
|
|
99
|
+
# Defensive: probe is best-effort, never raise to the caller.
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _pair_to_dict(pairs) -> dict[str, str]:
|
|
104
|
+
"""Accept either a tuple-of-pairs or a ready dict and return a dict."""
|
|
105
|
+
if isinstance(pairs, dict):
|
|
106
|
+
return pairs
|
|
107
|
+
return {pairs[i]: pairs[i + 1] for i in range(0, len(pairs) - 1, 2)}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _extract_context_limit(payload) -> int:
|
|
111
|
+
"""Pull the context window size from a provider's model-info response.
|
|
112
|
+
|
|
113
|
+
Tries the top-level dict first, then the common nested locations
|
|
114
|
+
(``data``, ``data.metadata``) since OpenAI-compatible providers are
|
|
115
|
+
inconsistent about where they put it.
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(payload, dict):
|
|
118
|
+
return 0
|
|
119
|
+
# 1) top-level fields
|
|
120
|
+
n = _first_positive_int(payload)
|
|
121
|
+
if n:
|
|
122
|
+
return n
|
|
123
|
+
# 2) nested under "data" (Anthropic shape, some OpenAI-compatible)
|
|
124
|
+
data = payload.get("data")
|
|
125
|
+
if isinstance(data, dict):
|
|
126
|
+
n = _first_positive_int(data) or _first_positive_int(data.get("metadata"))
|
|
127
|
+
if n:
|
|
128
|
+
return n
|
|
129
|
+
# 3) OpenAI list shape: {"data": [{...}]} — pick the first entry's fields
|
|
130
|
+
if isinstance(data, list):
|
|
131
|
+
for item in data:
|
|
132
|
+
if isinstance(item, dict):
|
|
133
|
+
n = _first_positive_int(item)
|
|
134
|
+
if n:
|
|
135
|
+
return n
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _first_positive_int(d) -> int:
|
|
140
|
+
"""Return the first value found under any of _CONTEXT_FIELDS that's a
|
|
141
|
+
positive integer. Strings that are all digits count too."""
|
|
142
|
+
if not isinstance(d, dict):
|
|
143
|
+
return 0
|
|
144
|
+
for key in _CONTEXT_FIELDS:
|
|
145
|
+
if key in d:
|
|
146
|
+
v = d[key]
|
|
147
|
+
try:
|
|
148
|
+
iv = int(v)
|
|
149
|
+
if iv > 0:
|
|
150
|
+
return iv
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
continue
|
|
153
|
+
return 0
|
coderio/mcp_loader.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) tool loader.
|
|
2
|
+
|
|
3
|
+
Reads Claude-Code-compatible ``.mcp.json`` config files, connects to the
|
|
4
|
+
configured MCP servers via ``langchain-mcp-adapters``, and returns the tools
|
|
5
|
+
as LangChain ``StructuredTool`` instances ready to pass to ``create_deep_agent``.
|
|
6
|
+
|
|
7
|
+
Config format (identical to Claude Code's ``.mcp.json``):
|
|
8
|
+
|
|
9
|
+
.. code-block:: json
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"filesystem": {
|
|
14
|
+
"command": "npx",
|
|
15
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
16
|
+
},
|
|
17
|
+
"github": {
|
|
18
|
+
"type": "http",
|
|
19
|
+
"url": "https://api.githubcopilot.com/mcp/",
|
|
20
|
+
"headers": {"Authorization": "Bearer xxx"}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Config discovery (two scopes, project overrides user on name collision):
|
|
26
|
+
- Project: ``{search_from}/.mcp.json``
|
|
27
|
+
- User: ``~/.coderio/mcp.json``
|
|
28
|
+
|
|
29
|
+
Tool naming: ``MultiServerMCPClient(tool_name_prefix=True)`` prefixes each tool
|
|
30
|
+
name with its server (e.g. ``filesystem_read_file``) so MCP tools never collide
|
|
31
|
+
with coderio's built-in ``read_file``, ``write_file``, etc.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
_log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _find_mcp_config(search_from: str | Path = ".") -> Path | None:
|
|
45
|
+
"""Find the project-level .mcp.json (walks upward from search_from).
|
|
46
|
+
|
|
47
|
+
Returns the path if found, None otherwise. Stops at the user's home dir
|
|
48
|
+
(the home ~/.coderio/mcp.json is the USER scope, handled separately).
|
|
49
|
+
"""
|
|
50
|
+
cur = Path(search_from).resolve()
|
|
51
|
+
home = Path.home()
|
|
52
|
+
while cur != home and cur != cur.parent:
|
|
53
|
+
candidate = cur / ".mcp.json"
|
|
54
|
+
if candidate.is_file():
|
|
55
|
+
return candidate
|
|
56
|
+
cur = cur.parent
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_mcp_json(path: Path | None) -> dict[str, dict]:
|
|
61
|
+
"""Read a single .mcp.json file, returning the mcpServers dict.
|
|
62
|
+
|
|
63
|
+
Returns {} on missing file, invalid JSON, or wrong structure — never raises.
|
|
64
|
+
"""
|
|
65
|
+
if path is None or not path.is_file():
|
|
66
|
+
return {}
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
69
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
70
|
+
_log.warning("Failed to read MCP config %s: %s", path, e)
|
|
71
|
+
return {}
|
|
72
|
+
servers = data.get("mcpServers", {})
|
|
73
|
+
if not isinstance(servers, dict):
|
|
74
|
+
return {}
|
|
75
|
+
return servers
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_mcp_config(
|
|
79
|
+
search_from: str | Path = ".",
|
|
80
|
+
user_dir: str | Path | None = None,
|
|
81
|
+
) -> dict[str, dict]:
|
|
82
|
+
"""Merge MCP server configs from project + user scope.
|
|
83
|
+
|
|
84
|
+
Project .mcp.json overrides user ~/.coderio/mcp.json on server-name
|
|
85
|
+
collision (project-specific config wins, same as skills/config layering).
|
|
86
|
+
|
|
87
|
+
Returns a dict of server_name → config_dict, possibly empty.
|
|
88
|
+
"""
|
|
89
|
+
project_servers = _read_mcp_json(_find_mcp_config(search_from))
|
|
90
|
+
# User scope: ~/.coderio/mcp.json
|
|
91
|
+
user_path = Path(user_dir) if user_dir else Path.home() / ".coderio"
|
|
92
|
+
user_servers = _read_mcp_json(user_path / "mcp.json")
|
|
93
|
+
# Merge: user first, project overrides.
|
|
94
|
+
merged = dict(user_servers)
|
|
95
|
+
merged.update(project_servers)
|
|
96
|
+
return merged
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_connection(server_name: str, cfg: dict) -> dict:
|
|
100
|
+
"""Convert a .mcp.json entry to langchain-mcp-adapters connection format.
|
|
101
|
+
|
|
102
|
+
Claude Code uses ``"type": "http"``; the adapter expects
|
|
103
|
+
``"transport": "streamable_http"``. stdio entries omit the transport key
|
|
104
|
+
(it's the default) — we add it explicitly.
|
|
105
|
+
|
|
106
|
+
ZCode-compatible fields (all optional, all backward-compatible):
|
|
107
|
+
- ``enabled`` (bool, default True): set False to temporarily disable a
|
|
108
|
+
server without removing its config. Legacy alias ``enable`` accepted.
|
|
109
|
+
- ``cwd`` (str, stdio only): working directory for the subprocess. Key
|
|
110
|
+
on Windows where ``npx`` may not be on PATH without it.
|
|
111
|
+
- ``timeoutMs`` (int): per-request timeout in milliseconds, forwarded to
|
|
112
|
+
the adapter as ``timeout`` (seconds). Default 30000 if unset by caller.
|
|
113
|
+
- ``type`` inference: when ``type`` is omitted, infer from the presence
|
|
114
|
+
of ``command`` (→ stdio) or ``url`` (→ http).
|
|
115
|
+
|
|
116
|
+
Legacy field migrations (ZCode auto-migrates these; we follow suit so
|
|
117
|
+
ZCode/Cursor-style configs work unchanged):
|
|
118
|
+
- ``type: "remote"`` → ``http``
|
|
119
|
+
- ``environment`` → ``env``
|
|
120
|
+
- ``http_headers`` → ``headers``
|
|
121
|
+
- ``enable`` → ``enabled``
|
|
122
|
+
"""
|
|
123
|
+
# --- enabled gate (ZCode-compatible): disabled servers return {} → skipped.
|
|
124
|
+
if not cfg.get("enabled", cfg.get("enable", True)):
|
|
125
|
+
_log.info("MCP server %r is disabled (enabled=false), skipping", server_name)
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
# --- legacy field migrations (applied before branch logic).
|
|
129
|
+
server_type = cfg.get("type", "")
|
|
130
|
+
if server_type == "remote": # ZCode legacy name for http
|
|
131
|
+
server_type = "http"
|
|
132
|
+
|
|
133
|
+
# timeoutMs → seconds (forwarded to adapter on each branch).
|
|
134
|
+
timeout_s: float | None = None
|
|
135
|
+
if "timeoutMs" in cfg:
|
|
136
|
+
try:
|
|
137
|
+
timeout_s = float(cfg["timeoutMs"]) / 1000.0
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
_log.warning("MCP server %r has invalid timeoutMs %r, ignoring", server_name, cfg["timeoutMs"])
|
|
140
|
+
|
|
141
|
+
# --- stdio branch (command present, or type explicitly stdio).
|
|
142
|
+
if "command" in cfg:
|
|
143
|
+
conn: dict[str, Any] = {
|
|
144
|
+
"transport": "stdio",
|
|
145
|
+
"command": cfg["command"],
|
|
146
|
+
"args": cfg.get("args", []),
|
|
147
|
+
}
|
|
148
|
+
# env (ZCode legacy: environment → env)
|
|
149
|
+
env = cfg.get("env", cfg.get("environment"))
|
|
150
|
+
if env:
|
|
151
|
+
conn["env"] = env
|
|
152
|
+
# cwd (Windows PATH robustness — npx/node often need an explicit cwd)
|
|
153
|
+
if "cwd" in cfg:
|
|
154
|
+
conn["cwd"] = cfg["cwd"]
|
|
155
|
+
if timeout_s is not None:
|
|
156
|
+
conn["timeout"] = timeout_s
|
|
157
|
+
return conn
|
|
158
|
+
|
|
159
|
+
# --- type inference: url present without command → http (ZCode behavior).
|
|
160
|
+
if server_type in ("http", "streamable_http") or ("url" in cfg and not server_type):
|
|
161
|
+
if "url" not in cfg:
|
|
162
|
+
_log.warning("MCP server %r has type=http but no url, skipping", server_name)
|
|
163
|
+
return {}
|
|
164
|
+
conn = {
|
|
165
|
+
"transport": "streamable_http",
|
|
166
|
+
"url": cfg["url"],
|
|
167
|
+
}
|
|
168
|
+
# headers (ZCode legacy: http_headers → headers)
|
|
169
|
+
headers = cfg.get("headers", cfg.get("http_headers"))
|
|
170
|
+
if headers:
|
|
171
|
+
conn["headers"] = headers
|
|
172
|
+
if timeout_s is not None:
|
|
173
|
+
conn["timeout"] = timeout_s
|
|
174
|
+
return conn
|
|
175
|
+
|
|
176
|
+
if server_type == "sse":
|
|
177
|
+
if "url" not in cfg:
|
|
178
|
+
_log.warning("MCP server %r has type=sse but no url, skipping", server_name)
|
|
179
|
+
return {}
|
|
180
|
+
conn = {
|
|
181
|
+
"transport": "sse",
|
|
182
|
+
"url": cfg["url"],
|
|
183
|
+
}
|
|
184
|
+
headers = cfg.get("headers", cfg.get("http_headers"))
|
|
185
|
+
if headers:
|
|
186
|
+
conn["headers"] = headers
|
|
187
|
+
if timeout_s is not None:
|
|
188
|
+
conn["timeout"] = timeout_s
|
|
189
|
+
return conn
|
|
190
|
+
|
|
191
|
+
# Unknown format — return a marker that will be skipped.
|
|
192
|
+
_log.warning("MCP server %r has unrecognized config: %s", server_name, cfg)
|
|
193
|
+
return {}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def load_mcp_tools(
|
|
197
|
+
search_from: str | Path = ".",
|
|
198
|
+
user_dir: str | Path | None = None,
|
|
199
|
+
) -> list:
|
|
200
|
+
"""Connect to all configured MCP servers and return their tools.
|
|
201
|
+
|
|
202
|
+
Returns a list of LangChain ``BaseTool`` instances (empty if no servers
|
|
203
|
+
configured or all connections failed). Tool names are prefixed with the
|
|
204
|
+
server name (e.g. ``filesystem_read_file``) to avoid collisions with
|
|
205
|
+
coderio's built-in tools.
|
|
206
|
+
|
|
207
|
+
Connection failures (server not installed, network error, bad config) are
|
|
208
|
+
logged as warnings and skipped — they never block coderio startup.
|
|
209
|
+
"""
|
|
210
|
+
config = load_mcp_config(search_from, user_dir)
|
|
211
|
+
if not config:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
# Build connections dict for MultiServerMCPClient.
|
|
215
|
+
connections: dict[str, dict] = {}
|
|
216
|
+
for name, raw_cfg in config.items():
|
|
217
|
+
if not isinstance(raw_cfg, dict):
|
|
218
|
+
continue
|
|
219
|
+
conn = _normalize_connection(name, raw_cfg)
|
|
220
|
+
if conn:
|
|
221
|
+
connections[name] = conn
|
|
222
|
+
|
|
223
|
+
if not connections:
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
228
|
+
except ImportError:
|
|
229
|
+
_log.warning(
|
|
230
|
+
"langchain-mcp-adapters not installed — MCP tools disabled. "
|
|
231
|
+
"Install with: pip install langchain-mcp-adapters"
|
|
232
|
+
)
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
client = MultiServerMCPClient(connections, tool_name_prefix=True)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
_log.warning("Failed to initialize MCP client: %s", e)
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
# Load tools from each server individually — one bad server (command not
|
|
242
|
+
# found, network error) must not prevent the good servers' tools from
|
|
243
|
+
# loading. MultiServerMCPClient connections are lazy; get_tools(server_name)
|
|
244
|
+
# only connects to that one server.
|
|
245
|
+
all_tools: list = []
|
|
246
|
+
for server_name in connections:
|
|
247
|
+
try:
|
|
248
|
+
server_tools = await client.get_tools(server_name=server_name)
|
|
249
|
+
all_tools.extend(server_tools)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
_log.warning("MCP server %r failed to load (skipped): %s", server_name, e)
|
|
252
|
+
|
|
253
|
+
_log.info("Loaded %d MCP tools from %d servers", len(all_tools), len(connections))
|
|
254
|
+
return all_tools
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def load_mcp_tools_sync(
|
|
258
|
+
search_from: str | Path = ".",
|
|
259
|
+
user_dir: str | Path | None = None,
|
|
260
|
+
) -> list:
|
|
261
|
+
"""Sync wrapper for load_mcp_tools. Called from build_runtime (sync context).
|
|
262
|
+
|
|
263
|
+
Uses asyncio.run() — safe because this runs once at startup, not inside an
|
|
264
|
+
existing event loop.
|
|
265
|
+
"""
|
|
266
|
+
import asyncio
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
return asyncio.run(load_mcp_tools(search_from, user_dir))
|
|
270
|
+
except RuntimeError:
|
|
271
|
+
# Already inside an event loop (shouldn't happen at startup, but guard
|
|
272
|
+
# just in case). Return empty — MCP tools are opt-in, not critical.
|
|
273
|
+
_log.warning("Cannot load MCP tools inside an existing event loop")
|
|
274
|
+
return []
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ToolCall:
|
|
10
|
+
id: str
|
|
11
|
+
name: str
|
|
12
|
+
args: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
def to_dict(self) -> dict:
|
|
15
|
+
return {"id": self.id, "name": self.name, "args": self.args}
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls, d: dict) -> "ToolCall":
|
|
19
|
+
return cls(id=d.get("id"), name=d.get("name"), args=d.get("args"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Message:
|
|
24
|
+
role: Literal["user", "assistant", "tool", "system"]
|
|
25
|
+
# content is normally a str; for multimodal user messages it may be a list of
|
|
26
|
+
# content blocks (e.g. [{"type":"text",...},{"type":"image",...}]) which
|
|
27
|
+
# langchain's HumanMessage accepts directly. For system messages with
|
|
28
|
+
# kind="phase_timeline", content holds the serialized timeline JSON.
|
|
29
|
+
content: str | list = ""
|
|
30
|
+
tool_calls: list[ToolCall] | None = None
|
|
31
|
+
tool_call_id: str | None = None
|
|
32
|
+
name: str | None = None
|
|
33
|
+
# Sub-kind for system-role messages: "phase_timeline" (turn-end phase record),
|
|
34
|
+
# "context_summary" (compacted history), "restart_checkpoint". Empty for
|
|
35
|
+
# non-system messages. Lets loaders filter system messages by purpose.
|
|
36
|
+
kind: str = ""
|
|
37
|
+
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def user(cls, content: str) -> "Message":
|
|
41
|
+
return cls(role="user", content=content)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def assistant(cls, content: str, tool_calls: list[ToolCall] | None = None) -> "Message":
|
|
45
|
+
return cls(role="assistant", content=content, tool_calls=tool_calls)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def tool_result(cls, tool_call_id: str, name: str, content: str) -> "Message":
|
|
49
|
+
return cls(role="tool", content=content, tool_call_id=tool_call_id, name=name)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def system(cls, content: str, kind: str = "") -> "Message":
|
|
53
|
+
"""A system-role message: phase timeline, context summary, or checkpoint.
|
|
54
|
+
Not shown in conversation history; filtered by ``kind``."""
|
|
55
|
+
return cls(role="system", content=content, kind=kind)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
d = {"role": self.role, "content": self.content, "timestamp": self.timestamp}
|
|
59
|
+
if self.tool_calls:
|
|
60
|
+
d["tool_calls"] = [tc.to_dict() for tc in self.tool_calls]
|
|
61
|
+
if self.tool_call_id:
|
|
62
|
+
d["tool_call_id"] = self.tool_call_id
|
|
63
|
+
if self.name:
|
|
64
|
+
d["name"] = self.name
|
|
65
|
+
if self.kind:
|
|
66
|
+
d["kind"] = self.kind
|
|
67
|
+
return d
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_dict(cls, d: dict) -> "Message":
|
|
71
|
+
return cls(
|
|
72
|
+
role=d["role"],
|
|
73
|
+
content=d.get("content", ""),
|
|
74
|
+
tool_calls=[ToolCall.from_dict(tc) for tc in d.get("tool_calls")] if d.get("tool_calls") else None,
|
|
75
|
+
tool_call_id=d.get("tool_call_id"),
|
|
76
|
+
name=d.get("name"),
|
|
77
|
+
kind=d.get("kind", ""),
|
|
78
|
+
timestamp=d.get("timestamp", time.strftime("%Y-%m-%dT%H:%M:%S")),
|
|
79
|
+
)
|