nanocode-cli 0.6.4__tar.gz → 0.6.6__tar.gz
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.
- {nanocode_cli-0.6.4/nanocode_cli.egg-info → nanocode_cli-0.6.6}/PKG-INFO +3 -2
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/README.md +2 -1
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/nanocode.py +413 -128
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6/nanocode_cli.egg-info}/PKG-INFO +3 -2
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/pyproject.toml +1 -1
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/LICENSE +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/MANIFEST.in +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/README.zh-CN.md +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.6.4 → nanocode_cli-0.6.6}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.6
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -240,7 +240,7 @@ model request
|
|
|
240
240
|
| conversation, compacted summaries, tools |
|
|
241
241
|
+--------------------------------------------------+
|
|
242
242
|
| user |
|
|
243
|
-
| Memory: goal, plan, known, date
|
|
243
|
+
| Memory: goal, plan, known, code index, date |
|
|
244
244
|
+--------------------------------------------------+
|
|
245
245
|
| user |
|
|
246
246
|
| FILE STATE: latest Read/Edit file view |
|
|
@@ -249,6 +249,7 @@ model request
|
|
|
249
249
|
|
|
250
250
|
Core rules:
|
|
251
251
|
|
|
252
|
+
- Environment holds only stable host facts (cwd, os, arch, detected commands) so it stays byte-identical across turns and keeps the conversation prefix cacheable; volatile state like the code index status lives in the late Memory section instead.
|
|
252
253
|
- Mid-turn assistant text and appended user input are kept as conversation.
|
|
253
254
|
- Earlier conversation is compacted into an explicit summary when the context grows too large.
|
|
254
255
|
- FILE STATE is updated by successful `Read` and `Edit` tools and shows current listed file ranges, with recent files first.
|
|
@@ -204,7 +204,7 @@ model request
|
|
|
204
204
|
| conversation, compacted summaries, tools |
|
|
205
205
|
+--------------------------------------------------+
|
|
206
206
|
| user |
|
|
207
|
-
| Memory: goal, plan, known, date
|
|
207
|
+
| Memory: goal, plan, known, code index, date |
|
|
208
208
|
+--------------------------------------------------+
|
|
209
209
|
| user |
|
|
210
210
|
| FILE STATE: latest Read/Edit file view |
|
|
@@ -213,6 +213,7 @@ model request
|
|
|
213
213
|
|
|
214
214
|
Core rules:
|
|
215
215
|
|
|
216
|
+
- Environment holds only stable host facts (cwd, os, arch, detected commands) so it stays byte-identical across turns and keeps the conversation prefix cacheable; volatile state like the code index status lives in the late Memory section instead.
|
|
216
217
|
- Mid-turn assistant text and appended user input are kept as conversation.
|
|
217
218
|
- Earlier conversation is compacted into an explicit summary when the context grows too large.
|
|
218
219
|
- FILE STATE is updated by successful `Read` and `Edit` tools and shows current listed file ranges, with recent files first.
|
|
@@ -41,7 +41,7 @@ from prompt_toolkit.buffer import Buffer
|
|
|
41
41
|
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
42
42
|
from prompt_toolkit.document import Document
|
|
43
43
|
from prompt_toolkit.filters import Condition, has_completions, is_done
|
|
44
|
-
from prompt_toolkit.formatted_text import FormattedText
|
|
44
|
+
from prompt_toolkit.formatted_text import ANSI, FormattedText
|
|
45
45
|
from prompt_toolkit.history import FileHistory
|
|
46
46
|
from prompt_toolkit.key_binding import KeyBindings
|
|
47
47
|
from prompt_toolkit.keys import Keys
|
|
@@ -59,7 +59,7 @@ from rich.console import Console
|
|
|
59
59
|
from rich.markdown import Markdown
|
|
60
60
|
from rich.rule import Rule
|
|
61
61
|
|
|
62
|
-
__version__ = "0.6.
|
|
62
|
+
__version__ = "0.6.6"
|
|
63
63
|
|
|
64
64
|
Json = dict[str, Any]
|
|
65
65
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
@@ -410,6 +410,7 @@ class ModelUsage:
|
|
|
410
410
|
total_tokens: int = 0
|
|
411
411
|
cached_prompt_tokens: int = 0
|
|
412
412
|
last_total_tokens: int = 0
|
|
413
|
+
last_prompt_tokens: int = 0
|
|
413
414
|
last_cached_prompt_tokens: int = 0
|
|
414
415
|
|
|
415
416
|
def add(self, usage: Any) -> None:
|
|
@@ -436,6 +437,7 @@ class ModelUsage:
|
|
|
436
437
|
self.total_tokens += total_tokens
|
|
437
438
|
self.cached_prompt_tokens += cached_tokens
|
|
438
439
|
self.last_total_tokens = total_tokens
|
|
440
|
+
self.last_prompt_tokens = prompt_tokens
|
|
439
441
|
self.last_cached_prompt_tokens = cached_tokens
|
|
440
442
|
|
|
441
443
|
|
|
@@ -659,6 +661,15 @@ class MCPToolInfo:
|
|
|
659
661
|
annotations: Json = field(default_factory=dict)
|
|
660
662
|
|
|
661
663
|
|
|
664
|
+
@dataclass
|
|
665
|
+
class MCPResourceInfo:
|
|
666
|
+
server: str
|
|
667
|
+
uri: str
|
|
668
|
+
name: str
|
|
669
|
+
description: str
|
|
670
|
+
mime_type: str = ""
|
|
671
|
+
|
|
672
|
+
|
|
662
673
|
@dataclass
|
|
663
674
|
class SystemInfo:
|
|
664
675
|
COMMANDS: ClassVar[tuple[str, ...]] = (
|
|
@@ -773,6 +784,7 @@ class SessionSnapshotCodec:
|
|
|
773
784
|
"total_tokens": usage.total_tokens,
|
|
774
785
|
"cached_prompt_tokens": usage.cached_prompt_tokens,
|
|
775
786
|
"last_cached_prompt_tokens": usage.last_cached_prompt_tokens,
|
|
787
|
+
"last_prompt_tokens": usage.last_prompt_tokens,
|
|
776
788
|
"last_total_tokens": usage.last_total_tokens,
|
|
777
789
|
}
|
|
778
790
|
|
|
@@ -858,6 +870,7 @@ class SessionSnapshotCodec:
|
|
|
858
870
|
usage.total_tokens = data.get("total_tokens", 0)
|
|
859
871
|
usage.cached_prompt_tokens = data.get("cached_prompt_tokens", 0)
|
|
860
872
|
usage.last_cached_prompt_tokens = data.get("last_cached_prompt_tokens", 0)
|
|
873
|
+
usage.last_prompt_tokens = data.get("last_prompt_tokens", 0)
|
|
861
874
|
usage.last_total_tokens = data.get("last_total_tokens", 0)
|
|
862
875
|
return usage
|
|
863
876
|
|
|
@@ -2880,8 +2893,8 @@ class QuestionTool(Tool):
|
|
|
2880
2893
|
|
|
2881
2894
|
class MCPTool(Tool):
|
|
2882
2895
|
NAME = "MCP"
|
|
2883
|
-
DESCRIPTION = "Call
|
|
2884
|
-
SIGNATURE = 'MCP(action="call"|"describe", server, tool
|
|
2896
|
+
DESCRIPTION = "Call/describe external MCP server tools, and list/read MCP resources"
|
|
2897
|
+
SIGNATURE = 'MCP(action="call"|"describe"|"list_resources"|"read_resource", server, tool?, arguments?, uri?)'
|
|
2885
2898
|
MUTATES = True
|
|
2886
2899
|
|
|
2887
2900
|
@classmethod
|
|
@@ -2891,8 +2904,8 @@ class MCPTool(Tool):
|
|
|
2891
2904
|
"properties": {
|
|
2892
2905
|
"action": {
|
|
2893
2906
|
"type": "string",
|
|
2894
|
-
"enum": ["call", "describe"],
|
|
2895
|
-
"description": '"call" invokes
|
|
2907
|
+
"enum": ["call", "describe", "list_resources", "read_resource"],
|
|
2908
|
+
"description": '"call" invokes a tool; "describe" returns a tool\'s schema; "list_resources" lists a server\'s resources; "read_resource" reads one by uri',
|
|
2896
2909
|
},
|
|
2897
2910
|
"server": {
|
|
2898
2911
|
"type": "string",
|
|
@@ -2900,14 +2913,18 @@ class MCPTool(Tool):
|
|
|
2900
2913
|
},
|
|
2901
2914
|
"tool": {
|
|
2902
2915
|
"type": "string",
|
|
2903
|
-
"description": "Remote MCP tool name",
|
|
2916
|
+
"description": "Remote MCP tool name (required for call/describe)",
|
|
2904
2917
|
},
|
|
2905
2918
|
"arguments": {
|
|
2906
2919
|
"type": "object",
|
|
2907
2920
|
"description": "Arguments for the remote tool (required for call)",
|
|
2908
2921
|
},
|
|
2922
|
+
"uri": {
|
|
2923
|
+
"type": "string",
|
|
2924
|
+
"description": "Resource URI (required for read_resource), e.g. scheme://path",
|
|
2925
|
+
},
|
|
2909
2926
|
},
|
|
2910
|
-
"required": ["action", "server"
|
|
2927
|
+
"required": ["action", "server"],
|
|
2911
2928
|
"additionalProperties": False,
|
|
2912
2929
|
}
|
|
2913
2930
|
|
|
@@ -2920,21 +2937,35 @@ class MCPTool(Tool):
|
|
|
2920
2937
|
raise ToolError("MCP requires named fields")
|
|
2921
2938
|
return self.args[0]
|
|
2922
2939
|
|
|
2940
|
+
ACTIONS: ClassVar[tuple[str, ...]] = ("call", "describe", "list_resources", "read_resource")
|
|
2941
|
+
|
|
2942
|
+
@classmethod
|
|
2943
|
+
def resolved_action(cls, payload: Json) -> str:
|
|
2944
|
+
"""Effective action; defaults to "call" when omitted but the envelope looks like an invocation."""
|
|
2945
|
+
action = str(payload.get("action") or "").strip()
|
|
2946
|
+
if action:
|
|
2947
|
+
return action
|
|
2948
|
+
if payload.get("tool") or payload.get("arguments") is not None:
|
|
2949
|
+
return "call"
|
|
2950
|
+
return action
|
|
2951
|
+
|
|
2923
2952
|
def needs_confirmation(self) -> bool:
|
|
2924
2953
|
payload = self.payload()
|
|
2925
|
-
|
|
2926
|
-
if action == "describe":
|
|
2954
|
+
if self.resolved_action(payload) != "call":
|
|
2927
2955
|
return False
|
|
2928
|
-
if
|
|
2956
|
+
if self.session.mcp is None:
|
|
2929
2957
|
return False
|
|
2930
2958
|
return self.session.mcp.tool_needs_confirmation(str(payload.get("server") or ""), str(payload.get("tool") or ""))
|
|
2931
2959
|
|
|
2932
2960
|
def short_args(self) -> list[str]:
|
|
2933
2961
|
payload = self.payload()
|
|
2934
|
-
action =
|
|
2962
|
+
action = self.resolved_action(payload)
|
|
2935
2963
|
server = str(payload.get("server") or "")
|
|
2936
2964
|
tool_name = str(payload.get("tool") or "")
|
|
2937
|
-
|
|
2965
|
+
if action == "read_resource":
|
|
2966
|
+
target = (server + " " + str(payload.get("uri") or "")).strip()
|
|
2967
|
+
else:
|
|
2968
|
+
target = (server + "." + tool_name).strip(".")
|
|
2938
2969
|
parts = [part for part in (action, target) if part]
|
|
2939
2970
|
arguments = payload.get("arguments")
|
|
2940
2971
|
if action == "call" and isinstance(arguments, dict) and arguments:
|
|
@@ -2948,7 +2979,7 @@ class MCPTool(Tool):
|
|
|
2948
2979
|
|
|
2949
2980
|
def call(self) -> str:
|
|
2950
2981
|
payload = self.payload()
|
|
2951
|
-
action =
|
|
2982
|
+
action = self.resolved_action(payload)
|
|
2952
2983
|
server = payload.get("server", "")
|
|
2953
2984
|
tool_name = payload.get("tool", "")
|
|
2954
2985
|
arguments = payload.get("arguments", {})
|
|
@@ -2962,8 +2993,20 @@ class MCPTool(Tool):
|
|
|
2962
2993
|
if action == "describe":
|
|
2963
2994
|
return mcp.describe_tool(server, tool_name)
|
|
2964
2995
|
if action == "call":
|
|
2965
|
-
|
|
2966
|
-
|
|
2996
|
+
prefix = mcp.auto_read_prefix(server, tool_name)
|
|
2997
|
+
try:
|
|
2998
|
+
output = mcp.call_tool(server, tool_name, arguments)
|
|
2999
|
+
except ToolError as error:
|
|
3000
|
+
raise ToolError(f"{error}\n\n{prefix}" if prefix else str(error)) from error
|
|
3001
|
+
return prefix + output if prefix else output
|
|
3002
|
+
if action == "list_resources":
|
|
3003
|
+
return mcp.list_resources(server)
|
|
3004
|
+
if action == "read_resource":
|
|
3005
|
+
return mcp.read_resource(server, str(payload.get("uri") or ""))
|
|
3006
|
+
raise ToolError(
|
|
3007
|
+
f"unknown MCP action {action!r}. Valid actions: {', '.join(self.ACTIONS)}. "
|
|
3008
|
+
f"To invoke a remote tool named {action!r}, use action=\"call\", tool={action!r}."
|
|
3009
|
+
)
|
|
2967
3010
|
|
|
2968
3011
|
TOOLS: tuple[type[Tool], ...] = (
|
|
2969
3012
|
MCPTool,
|
|
@@ -2993,9 +3036,7 @@ class ToolCall:
|
|
|
2993
3036
|
class ContextManager:
|
|
2994
3037
|
COMPACT_TITLE: ClassVar[str] = "--- Prior Conversation Summary (compacted) ---"
|
|
2995
3038
|
COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
|
|
2996
|
-
|
|
2997
|
-
MCP_DETAILS_MAX_TOOLS: ClassVar[int] = 20
|
|
2998
|
-
MCP_DETAILS_MAX_TOOL_CHARS: ClassVar[int] = 2_000
|
|
3039
|
+
MCP_DESCRIBE_BLOCK: ClassVar[re.Pattern] = re.compile(r"<MCPDescribe server=(\".*?\") tool=(\".*?\")>.*?</MCPDescribe>", re.DOTALL)
|
|
2999
3040
|
CODE_EXTENSIONS: ClassVar[set[str]] = set(
|
|
3000
3041
|
".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql "
|
|
3001
3042
|
".swift .toml .ts .tsx .vue .yaml .yml".split()
|
|
@@ -3022,7 +3063,6 @@ class ContextManager:
|
|
|
3022
3063
|
def model_messages(self, base_system: str, turn_messages: list[Json] | None = None) -> list[Json]:
|
|
3023
3064
|
file_context = self.file_context() or "(empty)"
|
|
3024
3065
|
mcp_tools = self.mcp_tools_context()
|
|
3025
|
-
mcp_details = self.mcp_tool_details()
|
|
3026
3066
|
|
|
3027
3067
|
messages: list[Json] = [
|
|
3028
3068
|
{"role": "system", "content": base_system.strip()},
|
|
@@ -3032,75 +3072,51 @@ class ContextManager:
|
|
|
3032
3072
|
if mcp_tools:
|
|
3033
3073
|
messages.append({"role": "user", "content": mcp_tools})
|
|
3034
3074
|
|
|
3035
|
-
messages.extend(self.session.messages)
|
|
3036
|
-
messages.extend(turn_messages or [])
|
|
3075
|
+
messages.extend(self.dedup_mcp_describes([*self.session.messages, *(turn_messages or [])]))
|
|
3037
3076
|
messages.append({"role": "user", "content": "--- Memory ---\n" + (self.memory_context(with_date=True) or "(empty)")})
|
|
3038
|
-
|
|
3039
|
-
if mcp_details:
|
|
3040
|
-
messages.append({"role": "user", "content": mcp_details})
|
|
3041
|
-
|
|
3042
3077
|
messages.append({"role": "user", "content": "--- FILE STATE ---\n" + file_context})
|
|
3043
3078
|
return Text.value(messages)
|
|
3044
3079
|
|
|
3080
|
+
def dedup_mcp_describes(self, messages: list[Json]) -> list[Json]:
|
|
3081
|
+
"""Collapse repeated MCP describe results to a pointer, keeping the first per (server, tool).
|
|
3082
|
+
|
|
3083
|
+
Pure send-time transform — stored history is never mutated. The first describe of a tool keeps
|
|
3084
|
+
its full schema (and stays in the cached prefix); a later duplicate shrinks to a one-line pointer
|
|
3085
|
+
the moment it appears, so the sent prefix stays byte-stable across calls and we reclaim the
|
|
3086
|
+
repeated schema tokens. Only ever collapses the newer occurrence, never an earlier (cached) one;
|
|
3087
|
+
if the first occurrence is later compacted away, the next one is promoted to full on its own.
|
|
3088
|
+
"""
|
|
3089
|
+
seen: dict[tuple[str, str], str] = {}
|
|
3090
|
+
result: list[Json] = []
|
|
3091
|
+
for message in messages:
|
|
3092
|
+
content = message.get("content")
|
|
3093
|
+
if message.get("role") != "tool" or not isinstance(content, str):
|
|
3094
|
+
result.append(message)
|
|
3095
|
+
continue
|
|
3096
|
+
match = self.MCP_DESCRIBE_BLOCK.search(content)
|
|
3097
|
+
if match is None:
|
|
3098
|
+
result.append(message)
|
|
3099
|
+
continue
|
|
3100
|
+
try:
|
|
3101
|
+
identity = (str(json.loads(match.group(1))), str(json.loads(match.group(2))))
|
|
3102
|
+
except (json.JSONDecodeError, ValueError):
|
|
3103
|
+
result.append(message)
|
|
3104
|
+
continue
|
|
3105
|
+
first_key = seen.get(identity)
|
|
3106
|
+
if first_key is None:
|
|
3107
|
+
key = re.search(r"\btr\.\d+\b", content)
|
|
3108
|
+
seen[identity] = key.group(0) if key else "above"
|
|
3109
|
+
result.append(message)
|
|
3110
|
+
continue
|
|
3111
|
+
marker = f"(repeat describe of {identity[0]}.{identity[1]}; schema shown earlier at {first_key}, unchanged)"
|
|
3112
|
+
result.append({**message, "content": self.MCP_DESCRIBE_BLOCK.sub(lambda _: marker, content)})
|
|
3113
|
+
return result
|
|
3114
|
+
|
|
3045
3115
|
def mcp_tools_context(self) -> str:
|
|
3046
3116
|
if self.session.mcp is None:
|
|
3047
3117
|
return ""
|
|
3048
3118
|
return self.session.mcp.render_tools_index()
|
|
3049
3119
|
|
|
3050
|
-
def mcp_tool_details(self) -> str:
|
|
3051
|
-
items = self.active_mcp_tool_details()
|
|
3052
|
-
if not items:
|
|
3053
|
-
return ""
|
|
3054
|
-
|
|
3055
|
-
chunks: list[str] = []
|
|
3056
|
-
chunks.append("--- MCP TOOL DETAILS ---")
|
|
3057
|
-
chunks.append('Details previously requested with MCP(action="describe").')
|
|
3058
|
-
chunks.append('Use MCP(action="call", server, tool, arguments) to call them.')
|
|
3059
|
-
chunks.append("")
|
|
3060
|
-
|
|
3061
|
-
for _source_order, server, tool, key, body in items:
|
|
3062
|
-
detail_block = f"{server}.{tool} source={key}\n{body}"
|
|
3063
|
-
chunks.append(detail_block)
|
|
3064
|
-
chunks.append("")
|
|
3065
|
-
|
|
3066
|
-
return "\n".join(chunks).strip()
|
|
3067
|
-
|
|
3068
|
-
def active_mcp_tool_details(self) -> list[tuple[int, str, str, str, str]]:
|
|
3069
|
-
details: dict[tuple[str, str], tuple[int, str, str]] = {}
|
|
3070
|
-
for order, record in enumerate(self.session.tool_records):
|
|
3071
|
-
if record.name != "MCP":
|
|
3072
|
-
continue
|
|
3073
|
-
for match in re.finditer(r'<MCPDescribe server=(".*?") tool=(".*?")>(.*?)</MCPDescribe>', record.output, re.DOTALL):
|
|
3074
|
-
try:
|
|
3075
|
-
server = str(json.loads(match.group(1)))
|
|
3076
|
-
tool = str(json.loads(match.group(2)))
|
|
3077
|
-
body = match.group(3).strip()
|
|
3078
|
-
except (json.JSONDecodeError, ValueError):
|
|
3079
|
-
continue
|
|
3080
|
-
details[(server, tool)] = (order, record.key, body)
|
|
3081
|
-
|
|
3082
|
-
items = sorted((order, server, tool, key, body) for (server, tool), (order, key, body) in details.items())
|
|
3083
|
-
items = items[-self.MCP_DETAILS_MAX_TOOLS :]
|
|
3084
|
-
active: list[tuple[int, str, str, str, str]] = []
|
|
3085
|
-
total_chars = len("--- MCP TOOL DETAILS ---\n") + 160
|
|
3086
|
-
truncated = False
|
|
3087
|
-
for order, server, tool, key, body in items:
|
|
3088
|
-
header = f"{server}.{tool} source={key}"
|
|
3089
|
-
detail_block = f"{header}\n{body}"
|
|
3090
|
-
if len(detail_block) > self.MCP_DETAILS_MAX_TOOL_CHARS:
|
|
3091
|
-
body = body[: max(0, self.MCP_DETAILS_MAX_TOOL_CHARS - len(header) - 24)] + "\n... detail truncated"
|
|
3092
|
-
detail_block = f"{header}\n{body}"
|
|
3093
|
-
truncated = True
|
|
3094
|
-
if total_chars + len(detail_block) + 1 > self.MCP_DETAILS_BUDGET:
|
|
3095
|
-
truncated = True
|
|
3096
|
-
break
|
|
3097
|
-
active.append((order, server, tool, key, body))
|
|
3098
|
-
total_chars += len(detail_block) + 1
|
|
3099
|
-
if truncated and active:
|
|
3100
|
-
order, server, tool, key, body = active[-1]
|
|
3101
|
-
active[-1] = (order, server, tool, key, body + '\n... MCP tool details truncated; use MCP(action="describe", server, tool) again if needed.')
|
|
3102
|
-
return active
|
|
3103
|
-
|
|
3104
3120
|
def update_percent(self, messages: list[Json]) -> int:
|
|
3105
3121
|
tokens = self.estimated_tokens(messages)
|
|
3106
3122
|
self.session.state.context_percent = min(100, tokens * 100 // self.session.settings.max_context_tokens)
|
|
@@ -3127,11 +3143,14 @@ class ContextManager:
|
|
|
3127
3143
|
return self.estimated_tokens(self.model_messages(base_system, turn_messages)) >= self.session.settings.max_context_tokens
|
|
3128
3144
|
|
|
3129
3145
|
def memory_context(self, *, with_date: bool = False) -> str:
|
|
3146
|
+
index_status = self.session.state.code_index_status or "missing"
|
|
3147
|
+
index_usable = "yes" if index_status in {"synced", "ready", "stale"} else "no"
|
|
3130
3148
|
rows = [
|
|
3131
3149
|
"Goal: " + (self.session.state.goal or "(empty; use Note for multi-step work)"),
|
|
3132
3150
|
"Plan:\n" + "\n".join(self.session.state.plan_rows() or ["- (empty; use Note for a short plan)"]),
|
|
3133
3151
|
"Known:\n" + "\n".join("- " + item for item in self.session.state.known or ["(empty)"]),
|
|
3134
3152
|
"Check: " + (self.session.state.check or "(empty)"),
|
|
3153
|
+
f"Code index: {index_status} (InspectCode usable: {index_usable})",
|
|
3135
3154
|
]
|
|
3136
3155
|
if with_date:
|
|
3137
3156
|
rows.append("Date: " + datetime.now().astimezone().strftime("%Y-%m-%d"))
|
|
@@ -3139,15 +3158,12 @@ class ContextManager:
|
|
|
3139
3158
|
|
|
3140
3159
|
def environment(self) -> str:
|
|
3141
3160
|
info = self.session.system_info
|
|
3142
|
-
index_status = self.session.state.code_index_status or "missing"
|
|
3143
|
-
index_usable = "yes" if index_status in {"synced", "ready", "stale"} else "no"
|
|
3144
3161
|
rows = [
|
|
3145
3162
|
f"- cwd: {info.cwd}",
|
|
3146
3163
|
f"- os: {info.os}",
|
|
3147
3164
|
f"- arch: {info.arch}",
|
|
3148
3165
|
f"- shell_timeout: {self.session.settings.shell_timeout}s",
|
|
3149
3166
|
"- detected_commands: " + (", ".join(info.commands) or "(none)"),
|
|
3150
|
-
f"- code_index: {index_status} (InspectCode usable: {index_usable})",
|
|
3151
3167
|
]
|
|
3152
3168
|
if branch := self.session.git_branch(self.session.cwd):
|
|
3153
3169
|
rows.append(f"- git_branch: {branch}")
|
|
@@ -3413,8 +3429,6 @@ class ContextManager:
|
|
|
3413
3429
|
for offset, record in enumerate(records):
|
|
3414
3430
|
if record.name == "Edit" and any(path in mins and offset >= mins[path] for path in paths.get(record.key, [])):
|
|
3415
3431
|
keep.add(record.key)
|
|
3416
|
-
for _order, _server, _tool, key, _body in self.active_mcp_tool_details():
|
|
3417
|
-
keep.add(key)
|
|
3418
3432
|
|
|
3419
3433
|
self.session.tool_records = [record for record in records if record.key in keep][-400:]
|
|
3420
3434
|
self.session.tool_results = {record.key: record.output for record in self.session.tool_records}
|
|
@@ -3689,10 +3703,14 @@ class MCPManager:
|
|
|
3689
3703
|
DESCRIBE_DESCRIPTION_LIMIT: ClassVar[int] = 1_000
|
|
3690
3704
|
DESCRIBE_ARGUMENT_LIMIT: ClassVar[int] = 50
|
|
3691
3705
|
DESCRIBE_ARGUMENT_DESCRIPTION_LIMIT: ClassVar[int] = 160
|
|
3706
|
+
INDEX_SCHEMA_LIMIT: ClassVar[int] = 700 # per-tool schema cap in the early (cached) tools index
|
|
3707
|
+
INDEX_TOTAL_LIMIT: ClassVar[int] = 16_000 # overall cap for the tools index block
|
|
3692
3708
|
|
|
3693
3709
|
def __init__(self, session: Session):
|
|
3694
3710
|
self.session = session
|
|
3695
3711
|
self.tools: dict[str, list[MCPToolInfo]] = {}
|
|
3712
|
+
self.resources: dict[str, list[MCPResourceInfo]] = {}
|
|
3713
|
+
self._auto_read_done: set[tuple[str, str]] = set()
|
|
3696
3714
|
self.server_errors: dict[str, str] = {}
|
|
3697
3715
|
self.server_skips: dict[str, str] = {}
|
|
3698
3716
|
self.lock = threading.Lock()
|
|
@@ -3802,6 +3820,8 @@ class MCPManager:
|
|
|
3802
3820
|
|
|
3803
3821
|
def _forget_locked(self, name: str) -> None:
|
|
3804
3822
|
self.tools.pop(name, None)
|
|
3823
|
+
self.resources.pop(name, None)
|
|
3824
|
+
self._auto_read_done = {entry for entry in self._auto_read_done if entry[0] != name}
|
|
3805
3825
|
self.server_errors.pop(name, None)
|
|
3806
3826
|
self.server_skips.pop(name, None)
|
|
3807
3827
|
|
|
@@ -3854,22 +3874,31 @@ class MCPManager:
|
|
|
3854
3874
|
self.set_server_error(config.name, headers)
|
|
3855
3875
|
return
|
|
3856
3876
|
|
|
3877
|
+
if config.auth == "oauth" and not self.oauth_token_store().has_server_tokens(config.url):
|
|
3878
|
+
self.set_server_error(config.name, "oauth login required; run /mcp login " + config.name)
|
|
3879
|
+
return
|
|
3857
3880
|
try:
|
|
3858
|
-
|
|
3859
|
-
if not self.oauth_token_store().has_server_tokens(config.url):
|
|
3860
|
-
self.set_server_error(config.name, "oauth login required; run /mcp login " + config.name)
|
|
3861
|
-
return
|
|
3862
|
-
tools = self.run_async(self._list_oauth_tools(config, headers))
|
|
3863
|
-
else:
|
|
3864
|
-
tools = self.run_async(self._list_tools(config, headers))
|
|
3865
|
-
tools_info = self._tools_info(config.name, tools)
|
|
3881
|
+
tools, resources = self.run_async(self._gather_assets(config, headers))
|
|
3866
3882
|
with self.lock:
|
|
3867
|
-
self.tools[config.name] =
|
|
3883
|
+
self.tools[config.name] = self._tools_info(config.name, tools)
|
|
3884
|
+
self.resources[config.name] = self._resources_info(config.name, resources)
|
|
3868
3885
|
self.server_errors.pop(config.name, None)
|
|
3869
3886
|
self.server_skips.pop(config.name, None)
|
|
3870
3887
|
except Exception as e:
|
|
3871
3888
|
self.set_server_error(config.name, self.error_text(e, timeout=self.discovery_timeout()))
|
|
3872
3889
|
|
|
3890
|
+
async def _gather_assets(self, config: MCPServerConfig, headers: dict[str, str]) -> tuple[Any, list[Any]]:
|
|
3891
|
+
"""Fetch tools and resources concurrently. Tool failure aborts discovery; resources are best-effort."""
|
|
3892
|
+
oauth = config.auth == "oauth"
|
|
3893
|
+
tools_co = self._list_oauth_tools(config, headers) if oauth else self._list_tools(config, headers)
|
|
3894
|
+
resources_co = self._list_oauth_resources(config, headers) if oauth else self._list_resources(config, headers)
|
|
3895
|
+
tools, resources = await asyncio.gather(tools_co, resources_co, return_exceptions=True)
|
|
3896
|
+
if isinstance(tools, BaseException):
|
|
3897
|
+
raise tools
|
|
3898
|
+
if isinstance(resources, BaseException):
|
|
3899
|
+
resources = []
|
|
3900
|
+
return tools, resources
|
|
3901
|
+
|
|
3873
3902
|
def set_server_error(self, name: str, error: str) -> None:
|
|
3874
3903
|
with self.lock:
|
|
3875
3904
|
self._forget_locked(name)
|
|
@@ -3908,6 +3937,26 @@ class MCPManager:
|
|
|
3908
3937
|
for t in tools
|
|
3909
3938
|
]
|
|
3910
3939
|
|
|
3940
|
+
def _resources_info(self, server: str, resources: Any) -> list[MCPResourceInfo]:
|
|
3941
|
+
infos: list[MCPResourceInfo] = []
|
|
3942
|
+
for r in resources or []:
|
|
3943
|
+
uri = str(getattr(r, "uri", "") or "")
|
|
3944
|
+
if not uri:
|
|
3945
|
+
continue
|
|
3946
|
+
infos.append(
|
|
3947
|
+
MCPResourceInfo(
|
|
3948
|
+
server=server,
|
|
3949
|
+
uri=uri,
|
|
3950
|
+
name=str(getattr(r, "name", "") or ""),
|
|
3951
|
+
description=str(getattr(r, "description", "") or ""),
|
|
3952
|
+
mime_type=str(getattr(r, "mimeType", "") or ""),
|
|
3953
|
+
)
|
|
3954
|
+
)
|
|
3955
|
+
return infos
|
|
3956
|
+
|
|
3957
|
+
def resource_info(self, server: str, uri: str) -> MCPResourceInfo | None:
|
|
3958
|
+
return next((res for res in self.resources.get(server, []) if res.uri == uri), None)
|
|
3959
|
+
|
|
3911
3960
|
@staticmethod
|
|
3912
3961
|
def tool_annotations(tool: Any) -> Json:
|
|
3913
3962
|
annotations = getattr(tool, "annotations", None)
|
|
@@ -4061,6 +4110,109 @@ class MCPManager:
|
|
|
4061
4110
|
text = self.normalize_result(result)
|
|
4062
4111
|
return f"<MCPCall server={json.dumps(server)} tool={json.dumps(tool_name)}>\n{text}\n</MCPCall>"
|
|
4063
4112
|
|
|
4113
|
+
def _resource_preamble(self, server: str) -> tuple[MCPServerConfig, dict[str, str]]:
|
|
4114
|
+
config = self.find_config(server)
|
|
4115
|
+
if config is None:
|
|
4116
|
+
raise ToolError(f"MCP server '{server}' not found")
|
|
4117
|
+
if config.error:
|
|
4118
|
+
raise ToolError(config.error)
|
|
4119
|
+
headers = self._build_mcp_headers(config)
|
|
4120
|
+
if isinstance(headers, str):
|
|
4121
|
+
raise ToolError(headers)
|
|
4122
|
+
if config.auth == "oauth" and not self.oauth_token_store().has_server_tokens(config.url):
|
|
4123
|
+
raise ToolError(f"MCP server '{server}' requires OAuth login; run /mcp login {server}")
|
|
4124
|
+
if server not in self.tools and server not in self.resources:
|
|
4125
|
+
self.discover_server(server)
|
|
4126
|
+
if server in self.server_errors:
|
|
4127
|
+
raise ToolError(f"MCP server '{server}' error: {self.server_errors[server]}")
|
|
4128
|
+
return config, headers
|
|
4129
|
+
|
|
4130
|
+
def list_resources(self, server: str) -> str:
|
|
4131
|
+
self._resource_preamble(server)
|
|
4132
|
+
resources = self.resources.get(server, [])
|
|
4133
|
+
lines = [f"<MCPResources server={json.dumps(server)}>"]
|
|
4134
|
+
if resources:
|
|
4135
|
+
lines.extend(self._format_resource_line(res) for res in resources)
|
|
4136
|
+
else:
|
|
4137
|
+
lines.append("(no resources advertised by this server)")
|
|
4138
|
+
lines.append("</MCPResources>")
|
|
4139
|
+
return "\n".join(lines)
|
|
4140
|
+
|
|
4141
|
+
def read_resource(self, server: str, uri: str) -> str:
|
|
4142
|
+
if not uri:
|
|
4143
|
+
raise ToolError("MCP read_resource requires a uri")
|
|
4144
|
+
config, headers = self._resource_preamble(server)
|
|
4145
|
+
try:
|
|
4146
|
+
result = self.run_async(
|
|
4147
|
+
self._read_oauth_resource(config, headers, uri) if config.auth == "oauth" else self._read_resource(config, headers, uri)
|
|
4148
|
+
)
|
|
4149
|
+
except Exception as e:
|
|
4150
|
+
raise ToolError("MCP resource read failed: " + self.error_text(e))
|
|
4151
|
+
text = self.normalize_resource(result)
|
|
4152
|
+
return f"<MCPResource server={json.dumps(server)} uri={json.dumps(uri)}>\n{text}\n</MCPResource>"
|
|
4153
|
+
|
|
4154
|
+
AUTO_READ_LIMIT: ClassVar[int] = 6_000 # per-doc cap for resources auto-injected on first tool call
|
|
4155
|
+
|
|
4156
|
+
def auto_read_prefix(self, server: str, tool_name: str) -> str:
|
|
4157
|
+
"""On the first call to a tool whose description references a resource doc, fetch it once.
|
|
4158
|
+
|
|
4159
|
+
Returns a block to attach to that call's result (so the grammar reaches the model on the
|
|
4160
|
+
first attempt and lands in cached history), or "" when there is nothing new to inject.
|
|
4161
|
+
Best-effort: failures are swallowed and never retried for the same uri.
|
|
4162
|
+
"""
|
|
4163
|
+
info = self.tool_info(server, tool_name)
|
|
4164
|
+
if info is None:
|
|
4165
|
+
return ""
|
|
4166
|
+
advertised = {res.uri for res in self.resources.get(server, [])}
|
|
4167
|
+
blocks: list[str] = []
|
|
4168
|
+
for uri in self._extract_uris(info.description):
|
|
4169
|
+
if (server, uri) in self._auto_read_done:
|
|
4170
|
+
continue
|
|
4171
|
+
scheme = uri.split("://", 1)[0].lower()
|
|
4172
|
+
# Only fetch things we can actually read over MCP: advertised resources or custom
|
|
4173
|
+
# (non-web) schemes. Plain http(s) links are left for the model to read explicitly.
|
|
4174
|
+
if uri not in advertised and scheme in ("http", "https"):
|
|
4175
|
+
continue
|
|
4176
|
+
self._auto_read_done.add((server, uri)) # mark before fetching so failures don't retry
|
|
4177
|
+
try:
|
|
4178
|
+
blocks.append(self.read_resource(server, uri)[: self.AUTO_READ_LIMIT])
|
|
4179
|
+
except Exception:
|
|
4180
|
+
continue
|
|
4181
|
+
if not blocks:
|
|
4182
|
+
return ""
|
|
4183
|
+
body = "\n".join(blocks)
|
|
4184
|
+
return f'<MCPAutoResources note="docs referenced by {server}.{tool_name}; injected once">\n{body}\n</MCPAutoResources>\n'
|
|
4185
|
+
|
|
4186
|
+
def normalize_resource(self, result: Any) -> str:
|
|
4187
|
+
items = result if isinstance(result, list) else [result]
|
|
4188
|
+
parts: list[str] = []
|
|
4189
|
+
for item in items:
|
|
4190
|
+
text = getattr(item, "text", None)
|
|
4191
|
+
if text:
|
|
4192
|
+
parts.append(str(text))
|
|
4193
|
+
continue
|
|
4194
|
+
blob = getattr(item, "blob", None)
|
|
4195
|
+
if blob is not None:
|
|
4196
|
+
mime = str(getattr(item, "mimeType", "") or "application/octet-stream")
|
|
4197
|
+
parts.append(f"<binary mimeType={json.dumps(mime)} bytes={len(blob)}/>")
|
|
4198
|
+
continue
|
|
4199
|
+
if hasattr(item, "model_dump"):
|
|
4200
|
+
parts.append(json.dumps(item.model_dump(mode="json"), ensure_ascii=False, indent=2))
|
|
4201
|
+
continue
|
|
4202
|
+
parts.append(str(item))
|
|
4203
|
+
text = "\n".join(part for part in parts if part).strip()
|
|
4204
|
+
if len(text) > self.RAW_OUTPUT_LIMIT:
|
|
4205
|
+
text = text[: self.RAW_OUTPUT_LIMIT] + f"\n<MCPOutputTruncated chars={json.dumps(len(text))}/>"
|
|
4206
|
+
return text
|
|
4207
|
+
|
|
4208
|
+
def _format_resource_line(self, info: MCPResourceInfo) -> str:
|
|
4209
|
+
desc = " ".join((info.description or "").split())
|
|
4210
|
+
if len(desc) > 100:
|
|
4211
|
+
desc = desc[:97] + "..."
|
|
4212
|
+
mime = f" [{info.mime_type}]" if info.mime_type else ""
|
|
4213
|
+
label = f"{info.uri}{mime}"
|
|
4214
|
+
return f"- {label} - {desc}" if desc else f"- {label}"
|
|
4215
|
+
|
|
4064
4216
|
def normalize_result(self, result: Any) -> str:
|
|
4065
4217
|
parts: list[str] = []
|
|
4066
4218
|
content = getattr(result, "content", result)
|
|
@@ -4099,6 +4251,36 @@ class MCPManager:
|
|
|
4099
4251
|
async with Client(self._transport(config, headers), timeout=timeout, init_timeout=timeout) as client:
|
|
4100
4252
|
return await asyncio.wait_for(client.call_tool(name, arguments), timeout=timeout)
|
|
4101
4253
|
|
|
4254
|
+
async def _list_resources(self, config: MCPServerConfig, headers: dict[str, str]) -> list[Any]:
|
|
4255
|
+
from fastmcp.client import Client
|
|
4256
|
+
|
|
4257
|
+
timeout = self.discovery_timeout()
|
|
4258
|
+
async with Client(self._transport(config, headers), timeout=timeout, init_timeout=timeout) as client:
|
|
4259
|
+
return await asyncio.wait_for(client.list_resources(), timeout=timeout)
|
|
4260
|
+
|
|
4261
|
+
async def _list_oauth_resources(self, config: MCPServerConfig, headers: dict[str, str]) -> list[Any]:
|
|
4262
|
+
from fastmcp.client import Client
|
|
4263
|
+
from fastmcp.client.transports import StreamableHttpTransport
|
|
4264
|
+
|
|
4265
|
+
timeout = self.discovery_timeout()
|
|
4266
|
+
async with Client(StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout) as client:
|
|
4267
|
+
return await asyncio.wait_for(client.list_resources(), timeout=timeout)
|
|
4268
|
+
|
|
4269
|
+
async def _read_resource(self, config: MCPServerConfig, headers: dict[str, str], uri: str) -> Any:
|
|
4270
|
+
from fastmcp.client import Client
|
|
4271
|
+
|
|
4272
|
+
timeout = self.call_timeout()
|
|
4273
|
+
async with Client(self._transport(config, headers), timeout=timeout, init_timeout=timeout) as client:
|
|
4274
|
+
return await asyncio.wait_for(client.read_resource(uri), timeout=timeout)
|
|
4275
|
+
|
|
4276
|
+
async def _read_oauth_resource(self, config: MCPServerConfig, headers: dict[str, str], uri: str) -> Any:
|
|
4277
|
+
from fastmcp.client import Client
|
|
4278
|
+
from fastmcp.client.transports import StreamableHttpTransport
|
|
4279
|
+
|
|
4280
|
+
timeout = self.call_timeout()
|
|
4281
|
+
async with Client(StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout) as client:
|
|
4282
|
+
return await asyncio.wait_for(client.read_resource(uri), timeout=timeout)
|
|
4283
|
+
|
|
4102
4284
|
async def _call_oauth_tool(self, config: MCPServerConfig, headers: dict[str, str], name: str, arguments: Json) -> Any:
|
|
4103
4285
|
from fastmcp.client import Client
|
|
4104
4286
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
@@ -4198,15 +4380,10 @@ class MCPManager:
|
|
|
4198
4380
|
desc = Tool.compact(str(prop.get("description", "") or ""), self.DESCRIBE_ARGUMENT_DESCRIPTION_LIMIT)
|
|
4199
4381
|
lines.append(f"- {name} {req} {typ}: {desc}")
|
|
4200
4382
|
lines.append("</arguments>")
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
summary = "No arguments."
|
|
4206
|
-
if len(summary) > 200:
|
|
4207
|
-
summary = summary[:197] + "..."
|
|
4208
|
-
lines.append(summary)
|
|
4209
|
-
lines.append("</schema_summary>")
|
|
4383
|
+
if isinstance(schema, dict) and schema:
|
|
4384
|
+
lines.append("<schema>")
|
|
4385
|
+
lines.append(json.dumps(schema, ensure_ascii=False, indent=2))
|
|
4386
|
+
lines.append("</schema>")
|
|
4210
4387
|
lines.append("</MCPDescribe>")
|
|
4211
4388
|
return "\n".join(lines)
|
|
4212
4389
|
|
|
@@ -4218,14 +4395,17 @@ class MCPManager:
|
|
|
4218
4395
|
lines: list[str] = []
|
|
4219
4396
|
lines.append("--- MCP TOOLS ---")
|
|
4220
4397
|
lines.append('Use MCP(action="call", server, tool, arguments) for external MCP server tools.')
|
|
4221
|
-
lines.append('Use MCP(action="describe", server, tool) for full
|
|
4398
|
+
lines.append('Use MCP(action="describe", server, tool) for the full schema when one is truncated below; the result stays in the conversation, so do not describe the same tool again once its schema is shown — just call it.')
|
|
4399
|
+
lines.append('Use MCP(action="read_resource", server, uri) to read a listed resource (e.g. docs describing how to build a tool\'s arguments). Read relevant resources before calling.')
|
|
4222
4400
|
lines.append("Format: server.tool(req: type; opt: type) - description")
|
|
4401
|
+
lines.append(" schema: <JSON Schema for the arguments object>")
|
|
4223
4402
|
lines.append("")
|
|
4224
4403
|
|
|
4225
4404
|
pending: list[str] = []
|
|
4226
4405
|
for config in configs:
|
|
4227
4406
|
tools = self.tools.get(config.name, [])
|
|
4228
|
-
|
|
4407
|
+
resources = self.resources.get(config.name, [])
|
|
4408
|
+
if not tools and not resources:
|
|
4229
4409
|
pending.append(f"- {config.name}: {self._pending_status(config.name)}")
|
|
4230
4410
|
continue
|
|
4231
4411
|
name_display = config.name.capitalize()
|
|
@@ -4234,6 +4414,9 @@ class MCPManager:
|
|
|
4234
4414
|
line = self._format_tool_line(config.name, tool)
|
|
4235
4415
|
if line:
|
|
4236
4416
|
lines.append(line)
|
|
4417
|
+
if resources:
|
|
4418
|
+
lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(config.name)}, uri=...):")
|
|
4419
|
+
lines.extend(self._format_resource_line(res) for res in resources)
|
|
4237
4420
|
lines.append("")
|
|
4238
4421
|
|
|
4239
4422
|
if pending:
|
|
@@ -4242,8 +4425,8 @@ class MCPManager:
|
|
|
4242
4425
|
lines.append("")
|
|
4243
4426
|
|
|
4244
4427
|
text = "\n".join(lines)
|
|
4245
|
-
if len(text) >
|
|
4246
|
-
text = text[:
|
|
4428
|
+
if len(text) > self.INDEX_TOTAL_LIMIT:
|
|
4429
|
+
text = text[: self.INDEX_TOTAL_LIMIT - 10] + "\n... MCP tools truncated; use /mcp tools for full list."
|
|
4247
4430
|
return text
|
|
4248
4431
|
|
|
4249
4432
|
def server_issue(self, name: str) -> tuple[str, str] | None:
|
|
@@ -4260,6 +4443,8 @@ class MCPManager:
|
|
|
4260
4443
|
return message if kind == "error" else "skipped: " + message
|
|
4261
4444
|
if self.discovery_status == "discovering":
|
|
4262
4445
|
return "discovering — tools not loaded yet; retry shortly"
|
|
4446
|
+
if name in self.tools:
|
|
4447
|
+
return "connected; no tools or resources advertised"
|
|
4263
4448
|
return "not connected"
|
|
4264
4449
|
|
|
4265
4450
|
MENTION_PATTERN = re.compile(r"@([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]+))?")
|
|
@@ -4312,6 +4497,10 @@ class MCPManager:
|
|
|
4312
4497
|
line = self._format_tool_line(server, info)
|
|
4313
4498
|
if line:
|
|
4314
4499
|
lines.append(line)
|
|
4500
|
+
resources = self.resources.get(server, [])
|
|
4501
|
+
if resources:
|
|
4502
|
+
lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(server)}, uri=...):")
|
|
4503
|
+
lines.extend(self._format_resource_line(res) for res in resources)
|
|
4315
4504
|
return "\n".join(lines)
|
|
4316
4505
|
|
|
4317
4506
|
def _format_tool_line(self, server: str, info: MCPToolInfo) -> str:
|
|
@@ -4324,8 +4513,40 @@ class MCPManager:
|
|
|
4324
4513
|
line = f"{server}.{info.name}{args_str} - {desc}"
|
|
4325
4514
|
if len(line) > 200:
|
|
4326
4515
|
line = line[:197] + "..."
|
|
4516
|
+
# The full description (often naming a resource doc with the argument grammar) is
|
|
4517
|
+
# truncated above, so surface any resource-like URIs it mentions explicitly.
|
|
4518
|
+
uris = self._extract_uris(info.description)
|
|
4519
|
+
if uris:
|
|
4520
|
+
line += "\n refs (read with MCP action=\"read_resource\"): " + ", ".join(uris)
|
|
4521
|
+
schema = self._schema_json(info.input_schema, self.INDEX_SCHEMA_LIMIT)
|
|
4522
|
+
if schema:
|
|
4523
|
+
line += f"\n schema: {schema}"
|
|
4327
4524
|
return line
|
|
4328
4525
|
|
|
4526
|
+
URI_PATTERN: ClassVar[re.Pattern] = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s'\"<>)\]}]+")
|
|
4527
|
+
|
|
4528
|
+
@classmethod
|
|
4529
|
+
def _extract_uris(cls, text: str, limit: int = 5) -> list[str]:
|
|
4530
|
+
"""Pull resource-like URIs out of free text, deduped and lightly de-punctuated."""
|
|
4531
|
+
seen: list[str] = []
|
|
4532
|
+
for match in cls.URI_PATTERN.findall(text or ""):
|
|
4533
|
+
uri = match.rstrip(".,;:")
|
|
4534
|
+
if uri not in seen:
|
|
4535
|
+
seen.append(uri)
|
|
4536
|
+
if len(seen) >= limit:
|
|
4537
|
+
break
|
|
4538
|
+
return seen
|
|
4539
|
+
|
|
4540
|
+
@staticmethod
|
|
4541
|
+
def _schema_json(schema: Json, limit: int) -> str:
|
|
4542
|
+
"""Render a remote tool's input schema as compact JSON, capped at `limit` chars (0 = no cap)."""
|
|
4543
|
+
if not isinstance(schema, dict) or not schema:
|
|
4544
|
+
return ""
|
|
4545
|
+
text = json.dumps(schema, ensure_ascii=False, separators=(",", ":"))
|
|
4546
|
+
if limit and len(text) > limit:
|
|
4547
|
+
text = text[: limit - 1].rstrip() + "… (truncated; MCP describe for full schema)"
|
|
4548
|
+
return text
|
|
4549
|
+
|
|
4329
4550
|
def _tool_args_summary(self, info: MCPToolInfo) -> str:
|
|
4330
4551
|
schema = info.input_schema or {}
|
|
4331
4552
|
props = schema.get("properties", {})
|
|
@@ -4510,9 +4731,16 @@ class ToolRunner:
|
|
|
4510
4731
|
if self.session.settings.debug:
|
|
4511
4732
|
return self.finish(call, output, failed=True, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
|
|
4512
4733
|
self.session.record_tool_error("-", call.name, call.args, output)
|
|
4513
|
-
self.output_fn(self.
|
|
4734
|
+
self.output_fn(self.reject_display(call, output, display=display, batch_suffix=batch_suffix))
|
|
4514
4735
|
return self.tool_message(call, "", output, failed=True, display=display)
|
|
4515
4736
|
|
|
4737
|
+
def reject_display(self, call: ToolCall, output: str, *, display: str | None = None, batch_suffix: str = "") -> str:
|
|
4738
|
+
# Argument/usage rejections are usually self-corrected on retry, so show a quiet one-liner
|
|
4739
|
+
# (rendered dim by UiPrinter) instead of the full red failed block. Full error still goes to
|
|
4740
|
+
# the model and to debug.
|
|
4741
|
+
reason = self.oneline(output.removeprefix("ToolError:").strip(), 60)
|
|
4742
|
+
return self.with_batch_suffix("tool " + (display or self.short_call(call)) + " · rejected: " + reason, batch_suffix)
|
|
4743
|
+
|
|
4516
4744
|
def finish(
|
|
4517
4745
|
self,
|
|
4518
4746
|
call: ToolCall,
|
|
@@ -4542,8 +4770,6 @@ class ToolRunner:
|
|
|
4542
4770
|
head = "tool " + ((key + " ") if key else ("- " if failed else "")) + (display or self.short_call(call))
|
|
4543
4771
|
if not failed and call.name in {"Read", "Edit"}:
|
|
4544
4772
|
return head + " -> FILE STATE"
|
|
4545
|
-
if not failed and call.name == "MCP" and "<MCPDescribe " in output:
|
|
4546
|
-
return head + " -> MCP TOOL DETAILS"
|
|
4547
4773
|
rows = [head]
|
|
4548
4774
|
if failed:
|
|
4549
4775
|
rows.append("status: failed")
|
|
@@ -5093,7 +5319,7 @@ You are nanocode, a concise terminal coding agent.
|
|
|
5093
5319
|
TOOLS:
|
|
5094
5320
|
- Available: Read LineCount List Find InspectCode Search Edit Bash Git Recall Note Question MCP.
|
|
5095
5321
|
- Use exact tool names and named parameters; obey each tool DESCRIPTION/SIGNATURE.
|
|
5096
|
-
- Files/code: Read/LineCount/List inspect files; Find/Search locate paths/text; InspectCode
|
|
5322
|
+
- Files/code: Read/LineCount/List inspect files; Find/Search locate paths/text; prefer InspectCode over Search for symbols (defs, refs, impls, callers/callees, outline) when code_index is usable.
|
|
5097
5323
|
- Changes/commands: Edit writes files; Git handles git; Bash is fallback when built-ins do not fit.
|
|
5098
5324
|
- State/external: Recall retrieves tr.N outputs; Note maintains goal/plan/known/check; MCP calls configured external tools.
|
|
5099
5325
|
- Restraint: Before calling "Question", make progress with other tools first; only ask when genuinely blocked, and batch related questions into one call.
|
|
@@ -5112,9 +5338,8 @@ FILE STATE:
|
|
|
5112
5338
|
- Read and Edit refresh FILE STATE; after Edit, trust the edited range.
|
|
5113
5339
|
|
|
5114
5340
|
FINAL:
|
|
5115
|
-
-
|
|
5116
|
-
-
|
|
5117
|
-
- Mention checks run, or say checks not run.\
|
|
5341
|
+
- Default to a few lines; scale to the task. Lead with the result; no preamble or recap.
|
|
5342
|
+
- Concise markdown in the user's language; note changed files and checks run (or not run).\
|
|
5118
5343
|
"""
|
|
5119
5344
|
|
|
5120
5345
|
def __init__(self, session: Session, input_fn=input, output_fn=print):
|
|
@@ -5309,6 +5534,9 @@ class UiPrinter:
|
|
|
5309
5534
|
self.output_fn = output_fn
|
|
5310
5535
|
self.color = output_fn is print and sys.stdout.isatty()
|
|
5311
5536
|
self.console = Console() if self.color else None
|
|
5537
|
+
# When set, render Rich answers to an ANSI string and emit via prompt_toolkit, so
|
|
5538
|
+
# answers printed from inside a running prompt app (queue input) aren't mangled by patch_stdout.
|
|
5539
|
+
self.capture_ansi = False
|
|
5312
5540
|
|
|
5313
5541
|
def emit(self, text: str = "") -> None:
|
|
5314
5542
|
if not self.color:
|
|
@@ -5321,6 +5549,13 @@ class UiPrinter:
|
|
|
5321
5549
|
self.emit(text)
|
|
5322
5550
|
return
|
|
5323
5551
|
assert self.console is not None
|
|
5552
|
+
if self.capture_ansi:
|
|
5553
|
+
console = Console(force_terminal=True, width=shutil.get_terminal_size().columns)
|
|
5554
|
+
with console.capture() as capture:
|
|
5555
|
+
console.print(Rule(style="bright_black", characters="─"))
|
|
5556
|
+
console.print(Markdown(text))
|
|
5557
|
+
print_formatted_text(ANSI(capture.get()), end="", flush=True)
|
|
5558
|
+
return
|
|
5324
5559
|
self.console.print(Rule(style="bright_black", characters="─"))
|
|
5325
5560
|
self.console.print(Markdown(text))
|
|
5326
5561
|
|
|
@@ -5344,7 +5579,9 @@ class UiPrinter:
|
|
|
5344
5579
|
def tool_segments(self, text: str) -> list[tuple[str, str]]:
|
|
5345
5580
|
segments = []
|
|
5346
5581
|
for line in text.splitlines() or [""]:
|
|
5347
|
-
if line.startswith("tool "):
|
|
5582
|
+
if line.startswith("tool ") and " · rejected:" in line:
|
|
5583
|
+
segments.append(("ansibrightblack", line))
|
|
5584
|
+
elif line.startswith("tool "):
|
|
5348
5585
|
body = line[5:]
|
|
5349
5586
|
call, sep, tail = body.partition(" -> ")
|
|
5350
5587
|
failed = body.endswith(" [failed]") or body.endswith(" [refused]")
|
|
@@ -5768,6 +6005,9 @@ class StatusBar:
|
|
|
5768
6005
|
|
|
5769
6006
|
|
|
5770
6007
|
class CommandLoop:
|
|
6008
|
+
# Commands safe to run from the background queue-input thread while the agent works: read-only
|
|
6009
|
+
# views plus /yolo, whose single atomic flag flip the agent simply reads at the next approval.
|
|
6010
|
+
QUEUE_RUN_COMMANDS: ClassVar[frozenset[str]] = frozenset({"/help", "/status", "/memory", "/mcp", "/yolo"})
|
|
5771
6011
|
MODEL_CONFIGURED_LABEL = "---- Configured models ----"
|
|
5772
6012
|
MODEL_DISCOVERED_LABEL = "---- Discovered models ----"
|
|
5773
6013
|
MODEL_LABELS = frozenset((MODEL_CONFIGURED_LABEL, MODEL_DISCOVERED_LABEL))
|
|
@@ -5873,7 +6113,13 @@ Tools:
|
|
|
5873
6113
|
def changed(buffer: Buffer) -> None:
|
|
5874
6114
|
self.queue_input_text = buffer.text
|
|
5875
6115
|
|
|
5876
|
-
buffer = Buffer(
|
|
6116
|
+
buffer = Buffer(
|
|
6117
|
+
document=Document(self.queue_input_text),
|
|
6118
|
+
multiline=False,
|
|
6119
|
+
on_text_changed=changed,
|
|
6120
|
+
completer=self.input_completer,
|
|
6121
|
+
complete_while_typing=False,
|
|
6122
|
+
)
|
|
5877
6123
|
control = BufferControl(buffer=buffer, input_processors=[BeforeInput(prompt)])
|
|
5878
6124
|
input_window = Window(control, height=1, dont_extend_height=True, wrap_lines=False)
|
|
5879
6125
|
bindings = KeyBindings()
|
|
@@ -5882,11 +6128,15 @@ Tools:
|
|
|
5882
6128
|
texts = [Text.clean(text.strip()) for text in texts if text.strip()]
|
|
5883
6129
|
if not texts:
|
|
5884
6130
|
return
|
|
5885
|
-
|
|
6131
|
+
queued = [text for text in texts if not text.startswith("/")]
|
|
6132
|
+
commands = [text for text in texts if text.startswith("/")]
|
|
6133
|
+
self.session.pending_user_inputs.extend(queued)
|
|
5886
6134
|
|
|
5887
6135
|
def show() -> None:
|
|
5888
|
-
for text in
|
|
6136
|
+
for text in queued:
|
|
5889
6137
|
self.emit("+ " + text)
|
|
6138
|
+
for text in commands:
|
|
6139
|
+
self.run_queued_command(text)
|
|
5890
6140
|
|
|
5891
6141
|
run_in_terminal(show)
|
|
5892
6142
|
|
|
@@ -5907,6 +6157,21 @@ Tools:
|
|
|
5907
6157
|
self.session.state.model_retry_count += 1
|
|
5908
6158
|
os.kill(os.getpid(), signal.SIGINT)
|
|
5909
6159
|
|
|
6160
|
+
@bindings.add("tab")
|
|
6161
|
+
def _tab(event):
|
|
6162
|
+
if buffer.complete_state:
|
|
6163
|
+
buffer.complete_next()
|
|
6164
|
+
return
|
|
6165
|
+
completions = list(self.input_completer.get_completions(buffer.document, CompleteEvent(completion_requested=True)))
|
|
6166
|
+
if len(completions) == 1:
|
|
6167
|
+
buffer.apply_completion(completions[0])
|
|
6168
|
+
else:
|
|
6169
|
+
buffer.start_completion(select_first=False)
|
|
6170
|
+
|
|
6171
|
+
@bindings.add("s-tab")
|
|
6172
|
+
def _shift_tab(event):
|
|
6173
|
+
buffer.complete_previous() if buffer.complete_state else buffer.start_completion(select_last=True)
|
|
6174
|
+
|
|
5910
6175
|
@bindings.add(Keys.BracketedPaste)
|
|
5911
6176
|
def _paste(event):
|
|
5912
6177
|
parts = event.data.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
@@ -5917,7 +6182,12 @@ Tools:
|
|
|
5917
6182
|
self.queue_input_text = parts[-1]
|
|
5918
6183
|
buffer.reset(Document(self.queue_input_text))
|
|
5919
6184
|
|
|
5920
|
-
|
|
6185
|
+
completion_space = ConditionalContainer(Window(height=12, dont_extend_height=True), filter=has_completions & ~is_done)
|
|
6186
|
+
root = FloatContainer(
|
|
6187
|
+
HSplit([input_window, completion_space, self.status_window(active=True)]),
|
|
6188
|
+
[Float(CompletionsMenu(max_height=12, scroll_offset=1), xcursor=True, ycursor=True, attach_to_window=input_window, transparent=True)],
|
|
6189
|
+
)
|
|
6190
|
+
app = self._make_app(Layout(root, focused_element=input_window), bindings)
|
|
5921
6191
|
self.queue_input_app = app
|
|
5922
6192
|
self.queue_input_active.set()
|
|
5923
6193
|
|
|
@@ -5938,6 +6208,24 @@ Tools:
|
|
|
5938
6208
|
if self.queue_input_app is app:
|
|
5939
6209
|
self.queue_input_app = None
|
|
5940
6210
|
|
|
6211
|
+
def run_queued_command(self, text: str) -> None:
|
|
6212
|
+
"""Dispatch a slash command typed in the queue input. Only read-only commands run while the
|
|
6213
|
+
agent is working; mutating/control commands would race the in-flight turn, so they are refused."""
|
|
6214
|
+
name = text.partition(" ")[0]
|
|
6215
|
+
if name not in self.QUEUE_RUN_COMMANDS:
|
|
6216
|
+
self.emit(f"{name} is unavailable while the agent is working; press Ctrl-C to run it.")
|
|
6217
|
+
return
|
|
6218
|
+
if name == "/mcp":
|
|
6219
|
+
sub = text.partition(" ")[2].split()
|
|
6220
|
+
if sub and sub[0] != "tools":
|
|
6221
|
+
self.emit("Only read-only /mcp (status, tools) is available while the agent is working.")
|
|
6222
|
+
return
|
|
6223
|
+
self.ui.capture_ansi = True
|
|
6224
|
+
try:
|
|
6225
|
+
self.command(text)
|
|
6226
|
+
finally:
|
|
6227
|
+
self.ui.capture_ansi = False
|
|
6228
|
+
|
|
5941
6229
|
def pause_queue_input(self) -> None:
|
|
5942
6230
|
self.queue_input_paused.set()
|
|
5943
6231
|
if self.queue_input_app is not None:
|
|
@@ -5967,11 +6255,8 @@ Tools:
|
|
|
5967
6255
|
UpdateChecker(self.session).start()
|
|
5968
6256
|
while True:
|
|
5969
6257
|
try:
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
user_input = self.read_input(initial_text="")
|
|
5973
|
-
elif self.input_history is not None:
|
|
5974
|
-
self.input_history.append_string(user_input)
|
|
6258
|
+
# Pre-fill any queued input into the prompt for review/edit instead of auto-submitting it.
|
|
6259
|
+
user_input = self.read_input(initial_text=self.drain_queued_input())
|
|
5975
6260
|
except EOFError:
|
|
5976
6261
|
self.emit("")
|
|
5977
6262
|
self.save_and_emit_resume()
|
|
@@ -6181,7 +6466,7 @@ Tools:
|
|
|
6181
6466
|
initial_text: str = "",
|
|
6182
6467
|
) -> str:
|
|
6183
6468
|
if self.input_history is None:
|
|
6184
|
-
return self.input_fn(prompt_text)
|
|
6469
|
+
return initial_text or self.input_fn(prompt_text)
|
|
6185
6470
|
|
|
6186
6471
|
def accept(buffer: Buffer) -> bool:
|
|
6187
6472
|
app.exit(result=buffer.text)
|
|
@@ -6750,8 +7035,8 @@ Tools:
|
|
|
6750
7035
|
index_message = (index_message + "; " if index_message else "") + "run /index"
|
|
6751
7036
|
elif index_status == "stale" and "run /index" not in index_message:
|
|
6752
7037
|
index_message = (index_message + "; " if index_message else "") + "run /index or wait for auto update"
|
|
6753
|
-
cache_ratio = (usage.cached_prompt_tokens * 100 / usage.
|
|
6754
|
-
last_cache_ratio = (usage.last_cached_prompt_tokens * 100 / usage.
|
|
7038
|
+
cache_ratio = (usage.cached_prompt_tokens * 100 / usage.prompt_tokens) if usage.prompt_tokens else 0
|
|
7039
|
+
last_cache_ratio = (usage.last_cached_prompt_tokens * 100 / usage.last_prompt_tokens) if usage.last_prompt_tokens else 0
|
|
6755
7040
|
rows = [
|
|
6756
7041
|
("workspace", "`" + self.session.cwd + "`"),
|
|
6757
7042
|
("session", "`" + self.session.uid + "`"),
|
|
@@ -6766,7 +7051,7 @@ Tools:
|
|
|
6766
7051
|
("goal", self.session.state.goal or "(empty)"),
|
|
6767
7052
|
(
|
|
6768
7053
|
"usage",
|
|
6769
|
-
f"calls `{usage.calls}`; total `{usage.total_tokens}`; cached `{usage.cached_prompt_tokens}` (`{cache_ratio:.1f}%`); last `{usage.last_cached_prompt_tokens}/{usage.
|
|
7054
|
+
f"calls `{usage.calls}`; total `{usage.total_tokens}`; cached `{usage.cached_prompt_tokens}/{usage.prompt_tokens}` (`{cache_ratio:.1f}%`); last `{usage.last_cached_prompt_tokens}/{usage.last_prompt_tokens}` (`{last_cache_ratio:.1f}%`)",
|
|
6770
7055
|
),
|
|
6771
7056
|
(
|
|
6772
7057
|
"runtime",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.6
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -240,7 +240,7 @@ model request
|
|
|
240
240
|
| conversation, compacted summaries, tools |
|
|
241
241
|
+--------------------------------------------------+
|
|
242
242
|
| user |
|
|
243
|
-
| Memory: goal, plan, known, date
|
|
243
|
+
| Memory: goal, plan, known, code index, date |
|
|
244
244
|
+--------------------------------------------------+
|
|
245
245
|
| user |
|
|
246
246
|
| FILE STATE: latest Read/Edit file view |
|
|
@@ -249,6 +249,7 @@ model request
|
|
|
249
249
|
|
|
250
250
|
Core rules:
|
|
251
251
|
|
|
252
|
+
- Environment holds only stable host facts (cwd, os, arch, detected commands) so it stays byte-identical across turns and keeps the conversation prefix cacheable; volatile state like the code index status lives in the late Memory section instead.
|
|
252
253
|
- Mid-turn assistant text and appended user input are kept as conversation.
|
|
253
254
|
- Earlier conversation is compacted into an explicit summary when the context grows too large.
|
|
254
255
|
- FILE STATE is updated by successful `Read` and `Edit` tools and shows current listed file ranges, with recent files first.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|