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.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. 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,3 @@
1
+ from coderio.session.message import Message, ToolCall
2
+
3
+ __all__ = ["Message", "ToolCall"]
@@ -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
+ )