python-agent-harness 1.5.5.6__tar.gz → 1.5.5.7__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.
Files changed (105) hide show
  1. {python_agent_harness-1.5.5.6/python_agent_harness.egg-info → python_agent_harness-1.5.5.7}/PKG-INFO +1 -1
  2. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/pyproject.toml +1 -1
  3. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/__init__.py +1 -1
  4. python_agent_harness-1.5.5.7/python_agent_harness/tools/lsp.py +273 -0
  5. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7/python_agent_harness.egg-info}/PKG-INFO +1 -1
  6. python_agent_harness-1.5.5.6/python_agent_harness/tools/lsp.py +0 -243
  7. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/LICENSE +0 -0
  8. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/README.md +0 -0
  9. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/__main__.py +0 -0
  10. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/agent.py +0 -0
  11. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/cli.py +0 -0
  12. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/client.py +0 -0
  13. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/commands.py +0 -0
  14. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/config.py +0 -0
  15. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/context_manager.py +0 -0
  16. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/diffrender.py +0 -0
  17. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/lsp/__init__.py +0 -0
  18. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/lsp/client.py +0 -0
  19. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/lsp/config.py +0 -0
  20. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/lsp/manager.py +0 -0
  21. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/mcp/__init__.py +0 -0
  22. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/mcp/client.py +0 -0
  23. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/mcp/config.py +0 -0
  24. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/mcp/manager.py +0 -0
  25. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/models.py +0 -0
  26. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/persistence.py +0 -0
  27. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/planmode.py +0 -0
  28. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/agent.md +0 -0
  29. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  30. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/build-switch.md +0 -0
  31. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/commands/explain.md +0 -0
  32. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/compact.md +0 -0
  33. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/initialize.md +0 -0
  34. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/plan-mode.md +0 -0
  35. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/plan.md +0 -0
  36. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/review.md +0 -0
  37. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/subagent.md +0 -0
  38. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/summary.md +0 -0
  39. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  40. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts/title.md +0 -0
  41. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/prompts.py +0 -0
  42. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/session.py +0 -0
  43. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/subagent.py +0 -0
  44. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/token_estimator.py +0 -0
  45. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tool_runner.py +0 -0
  46. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/__init__.py +0 -0
  47. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/agent_tool.py +0 -0
  48. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/base.py +0 -0
  49. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/bash.py +0 -0
  50. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/bash_win.py +0 -0
  51. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/diffapply.py +0 -0
  52. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/edit.py +0 -0
  53. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/edit_mac.py +0 -0
  54. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/edit_win.py +0 -0
  55. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/filesystem.py +0 -0
  56. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/glob.py +0 -0
  57. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/glob_mac.py +0 -0
  58. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/glob_win.py +0 -0
  59. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/grep.py +0 -0
  60. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/grep_mac.py +0 -0
  61. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/grep_win.py +0 -0
  62. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/insert.py +0 -0
  63. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/mcp.py +0 -0
  64. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/mkdir.py +0 -0
  65. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/planexit.py +0 -0
  66. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/question.py +0 -0
  67. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/read.py +0 -0
  68. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/skill.py +0 -0
  69. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/todo.py +0 -0
  70. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tools/write.py +0 -0
  71. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tui/__init__.py +0 -0
  72. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tui/commands.py +0 -0
  73. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tui/core.py +0 -0
  74. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tui/input.py +0 -0
  75. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness/tui/render.py +0 -0
  76. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  77. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  78. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness.egg-info/entry_points.txt +0 -0
  79. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness.egg-info/requires.txt +0 -0
  80. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/python_agent_harness.egg-info/top_level.txt +0 -0
  81. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/setup.cfg +0 -0
  82. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_agents_md.py +0 -0
  83. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_cli.py +0 -0
  84. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_client.py +0 -0
  85. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_commands.py +0 -0
  86. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_concurrent_subagents.py +0 -0
  87. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_config.py +0 -0
  88. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_context_rules.py +0 -0
  89. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_custom_agents.py +0 -0
  90. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_diffrender.py +0 -0
  91. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_filesystem.py +0 -0
  92. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_invariants.py +0 -0
  93. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_lsp.py +0 -0
  94. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_mcp.py +0 -0
  95. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_models.py +0 -0
  96. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_persistence.py +0 -0
  97. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_planmode.py +0 -0
  98. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_prompts.py +0 -0
  99. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_scenarios.py +0 -0
  100. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_session.py +0 -0
  101. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_subagent.py +0 -0
  102. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_subagent_isolation.py +0 -0
  103. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_todos_scope.py +0 -0
  104. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_token_estimator.py +0 -0
  105. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.7}/tests/test_tools_misc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.6
3
+ Version: 1.5.5.7
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.6"
7
+ version = "1.5.5.7"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -7,7 +7,7 @@ from .mcp.manager import MCPManager
7
7
  from .models import AgentMode, Message, ToolCall, ToolSpec
8
8
  from .session import Session
9
9
 
10
- __version__ = "1.5.5.6"
10
+ __version__ = "1.5.5.7"
11
11
 
12
12
  __all__ = [
13
13
  "Client",
@@ -0,0 +1,273 @@
1
+ """Agent-facing LSP code-intelligence tool.
2
+
3
+ A small OpenCode-style surface over the built-in LSP client. The model sees
4
+ 1-based file/line/character coordinates; the client translates them to LSP's
5
+ 0-based representation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import json
12
+ import os
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import unquote, urlparse
17
+
18
+ from ..lsp import LSPError, get_client
19
+ from .base import Tool, ToolContext
20
+
21
+ OPERATIONS = (
22
+ "goToDefinition",
23
+ "findReferences",
24
+ "hover",
25
+ "documentSymbol",
26
+ "workspaceSymbol",
27
+ "goToImplementation",
28
+ "prepareCallHierarchy",
29
+ "incomingCalls",
30
+ "outgoingCalls",
31
+ )
32
+
33
+ DESCRIPTION = """Interact with Language Server Protocol (LSP) servers to get code intelligence features.
34
+
35
+ Supported operations:
36
+ - goToDefinition: Find where a symbol is defined
37
+ - findReferences: Find all references to a symbol
38
+ - hover: Get hover information (documentation, type info) for a symbol
39
+ - documentSymbol: Get all symbols (functions, classes, variables) in a document
40
+ - workspaceSymbol: List project-wide symbols matching a query string
41
+ - goToImplementation: Find implementations of an interface or abstract method
42
+ - prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
43
+ - incomingCalls: Find all functions/methods that call the function at a position
44
+ - outgoingCalls: Find all functions/methods called by the function at a position
45
+
46
+ All position-based operations use 1-based line and character numbers, as shown in editors.
47
+ workspaceSymbol uses file_path only to select the workspace/LSP server and query is optional.
48
+ """
49
+
50
+ PARAMETERS = {
51
+ "type": "object",
52
+ "properties": {
53
+ "operation": {
54
+ "type": "string",
55
+ "enum": list(OPERATIONS),
56
+ "description": "The LSP operation to perform",
57
+ },
58
+ "file_path": {
59
+ "type": "string",
60
+ "description": "The absolute or relative path to the file",
61
+ },
62
+ "line": {
63
+ "type": "integer",
64
+ "minimum": 1,
65
+ "description": (
66
+ "The line number (1-based, as shown in editors). "
67
+ "Required for all operations except workspaceSymbol."
68
+ ),
69
+ },
70
+ "character": {
71
+ "type": "integer",
72
+ "minimum": 1,
73
+ "description": (
74
+ "The character offset (1-based, as shown in editors). "
75
+ "Required for all operations except workspaceSymbol."
76
+ ),
77
+ },
78
+ "query": {
79
+ "type": "string",
80
+ "description": "Search query for workspaceSymbol. Empty string requests all symbols.",
81
+ },
82
+ },
83
+ "required": ["operation", "file_path"],
84
+ }
85
+
86
+ # Operations that require a position (line/character).
87
+ _POSITION_OPS = frozenset(o for o in OPERATIONS if o != "workspaceSymbol")
88
+
89
+
90
+ def _uri_to_path(uri: str) -> str:
91
+ parsed = urlparse(uri)
92
+ if parsed.scheme == "file":
93
+ path = unquote(parsed.path)
94
+ if os.name == "nt" and path.startswith("/") and len(path) >= 3 and path[2] == ":":
95
+ path = path[1:]
96
+ return os.path.normpath(path)
97
+ return uri
98
+
99
+
100
+ def _jsonable(value: Any) -> Any:
101
+ if isinstance(value, list):
102
+ return [_jsonable(v) for v in value]
103
+ if isinstance(value, dict):
104
+ return {k: _jsonable(v) for k, v in value.items()}
105
+ return value
106
+
107
+
108
+ def _resolve_path(raw_path: str, cwd: str) -> str:
109
+ if not os.path.isabs(raw_path):
110
+ return os.path.realpath(os.path.abspath(os.path.join(cwd, raw_path)))
111
+ return os.path.realpath(raw_path)
112
+
113
+
114
+ def _to_lsp_position(lines: list[str], line: int, character: int, encoding: str) -> dict[str, int]:
115
+ source_line = lines[line - 1].rstrip("\r\n")
116
+ py_index = min(character - 1, len(source_line))
117
+ if encoding == "utf-8":
118
+ lsp_character = len(source_line[:py_index].encode("utf-8"))
119
+ elif encoding == "utf-32":
120
+ lsp_character = py_index
121
+ else:
122
+ lsp_character = len(source_line[:py_index].encode("utf-16-le")) // 2
123
+ return {"line": line - 1, "character": lsp_character}
124
+
125
+
126
+ def _format_result(result: Any, operation: str) -> str:
127
+ if result is None:
128
+ result = []
129
+ if isinstance(result, (dict, list)) and not result:
130
+ return f"No results found for {operation}"
131
+ return json.dumps(_jsonable(result), ensure_ascii=False, indent=2)
132
+
133
+
134
+ # --- Operation handlers -------------------------------------------------
135
+ # Each handler receives (client, uri, position, args) and returns the raw
136
+ # LSP response (or a str error message for early-exit cases like empty
137
+ # call-hierarchy preparation).
138
+
139
+
140
+ def _op_definition(client, uri, position, args) -> Any:
141
+ return client.request(
142
+ "textDocument/definition",
143
+ {"textDocument": {"uri": uri}, "position": position},
144
+ )
145
+
146
+
147
+ def _op_references(client, uri, position, args) -> Any:
148
+ return client.request(
149
+ "textDocument/references",
150
+ {
151
+ "textDocument": {"uri": uri},
152
+ "position": position,
153
+ "context": {"includeDeclaration": True},
154
+ },
155
+ )
156
+
157
+
158
+ def _op_hover(client, uri, position, args) -> Any:
159
+ return client.request(
160
+ "textDocument/hover", {"textDocument": {"uri": uri}, "position": position}
161
+ )
162
+
163
+
164
+ def _op_document_symbol(client, uri, position, args) -> Any:
165
+ return client.request("textDocument/documentSymbol", {"textDocument": {"uri": uri}})
166
+
167
+
168
+ def _op_workspace_symbol(client, uri, position, args) -> Any:
169
+ return client.request("workspace/symbol", {"query": str(args.get("query", ""))})
170
+
171
+
172
+ def _op_implementation(client, uri, position, args) -> Any:
173
+ return client.request(
174
+ "textDocument/implementation",
175
+ {"textDocument": {"uri": uri}, "position": position},
176
+ )
177
+
178
+
179
+ def _op_prepare_call_hierarchy(client, uri, position, args) -> Any:
180
+ return client.request(
181
+ "textDocument/prepareCallHierarchy",
182
+ {"textDocument": {"uri": uri}, "position": position},
183
+ )
184
+
185
+
186
+ def _op_call_hierarchy(direction: str) -> Callable[..., Any]:
187
+ def handler(client, uri, position, args) -> Any:
188
+ prepared = client.request(
189
+ "textDocument/prepareCallHierarchy",
190
+ {"textDocument": {"uri": uri}, "position": position},
191
+ )
192
+ if not isinstance(prepared, list) or not prepared:
193
+ return "No call hierarchy item found at this position"
194
+ item = prepared[0]
195
+ method = (
196
+ "callHierarchy/incomingCalls"
197
+ if direction == "incoming"
198
+ else "callHierarchy/outgoingCalls"
199
+ )
200
+ return client.request(method, {"item": item})
201
+
202
+ return handler
203
+
204
+
205
+ _DISPATCH: dict[str, Callable[..., Any]] = {
206
+ "goToDefinition": _op_definition,
207
+ "findReferences": _op_references,
208
+ "hover": _op_hover,
209
+ "documentSymbol": _op_document_symbol,
210
+ "workspaceSymbol": _op_workspace_symbol,
211
+ "goToImplementation": _op_implementation,
212
+ "prepareCallHierarchy": _op_prepare_call_hierarchy,
213
+ "incomingCalls": _op_call_hierarchy("incoming"),
214
+ "outgoingCalls": _op_call_hierarchy("outgoing"),
215
+ }
216
+
217
+
218
+ class LSP(Tool):
219
+ name = "LSP"
220
+ description = DESCRIPTION
221
+ parameters = PARAMETERS
222
+ is_readonly = True
223
+
224
+ def run(self, args: dict, ctx: ToolContext) -> str:
225
+ operation = args.get("operation")
226
+ if operation not in OPERATIONS:
227
+ return f"Error: unsupported LSP operation {operation!r}"
228
+
229
+ raw_path = str(args.get("file_path", ""))
230
+ if not raw_path:
231
+ return "Error: file_path must not be empty"
232
+ path = _resolve_path(raw_path, ctx.cwd)
233
+ if not os.path.isfile(path):
234
+ return f"Error: File not found: {raw_path}"
235
+
236
+ needs_position = operation in _POSITION_OPS
237
+
238
+ try:
239
+ line = int(args.get("line", 1))
240
+ character = int(args.get("character", 1))
241
+ except (TypeError, ValueError):
242
+ return "Error: line and character must be integers"
243
+ if needs_position and (line < 1 or character < 1):
244
+ return "Error: line and character must be >= 1"
245
+
246
+ try:
247
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
248
+ except (OSError, UnicodeError) as e:
249
+ return f"Error: failed to read {raw_path}: {e}"
250
+ lines = text.splitlines(keepends=True)
251
+ if needs_position and line > len(lines):
252
+ return f"Error: line {line} is beyond end of file ({len(lines)} lines)"
253
+
254
+ try:
255
+ client, _ = get_client(path, ctx.cwd, ctx.config_path)
256
+ uri = Path(path).as_uri()
257
+ client.open_document(uri, text)
258
+ try:
259
+ position: dict[str, int] | None = None
260
+ if needs_position:
261
+ position = _to_lsp_position(lines, line, character, client.position_encoding)
262
+
263
+ handler = _DISPATCH[operation]
264
+ result = handler(client, uri, position, args)
265
+ finally:
266
+ with contextlib.suppress(Exception):
267
+ client.close_document(uri)
268
+ except (LSPError, ValueError) as e:
269
+ return f"Error: {e}"
270
+
271
+ if isinstance(result, str):
272
+ return result
273
+ return _format_result(result, operation)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.6
3
+ Version: 1.5.5.7
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -1,243 +0,0 @@
1
- """Agent-facing LSP code-intelligence tool.
2
-
3
- A small OpenCode-style surface over the built-in LSP client. The model sees
4
- 1-based file/line/character coordinates; the client translates them to LSP's
5
- 0-based representation.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import contextlib
11
- import json
12
- import os
13
- from pathlib import Path
14
- from typing import Any
15
- from urllib.parse import unquote, urlparse
16
-
17
- from ..lsp import LSPError, get_client
18
- from .base import Tool, ToolContext
19
-
20
- OPERATIONS = (
21
- "goToDefinition",
22
- "findReferences",
23
- "hover",
24
- "documentSymbol",
25
- "workspaceSymbol",
26
- "goToImplementation",
27
- "prepareCallHierarchy",
28
- "incomingCalls",
29
- "outgoingCalls",
30
- )
31
-
32
- DESCRIPTION = """Interact with Language Server Protocol (LSP) servers to get code intelligence features.
33
-
34
- Supported operations:
35
- - goToDefinition: Find where a symbol is defined
36
- - findReferences: Find all references to a symbol
37
- - hover: Get hover information (documentation, type info) for a symbol
38
- - documentSymbol: Get all symbols (functions, classes, variables) in a document
39
- - workspaceSymbol: List project-wide symbols matching a query string
40
- - goToImplementation: Find implementations of an interface or abstract method
41
- - prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
42
- - incomingCalls: Find all functions/methods that call the function at a position
43
- - outgoingCalls: Find all functions/methods called by the function at a position
44
-
45
- All position-based operations use 1-based line and character numbers, as shown in editors.
46
- workspaceSymbol uses file_path only to select the workspace/LSP server and query is optional.
47
- """
48
-
49
- PARAMETERS = {
50
- "type": "object",
51
- "properties": {
52
- "operation": {
53
- "type": "string",
54
- "enum": list(OPERATIONS),
55
- "description": "The LSP operation to perform",
56
- },
57
- "file_path": {
58
- "type": "string",
59
- "description": "The absolute or relative path to the file",
60
- },
61
- "line": {
62
- "type": "integer",
63
- "minimum": 1,
64
- "description": (
65
- "The line number (1-based, as shown in editors). "
66
- "Required for all operations except workspaceSymbol."
67
- ),
68
- },
69
- "character": {
70
- "type": "integer",
71
- "minimum": 1,
72
- "description": (
73
- "The character offset (1-based, as shown in editors). "
74
- "Required for all operations except workspaceSymbol."
75
- ),
76
- },
77
- "query": {
78
- "type": "string",
79
- "description": "Search query for workspaceSymbol. Empty string requests all symbols.",
80
- },
81
- },
82
- "required": ["operation", "file_path"],
83
- }
84
-
85
-
86
- def _uri_to_path(uri: str) -> str:
87
- parsed = urlparse(uri)
88
- if parsed.scheme == "file":
89
- path = unquote(parsed.path)
90
- if os.name == "nt" and path.startswith("/") and len(path) >= 3 and path[2] == ":":
91
- path = path[1:]
92
- return os.path.normpath(path)
93
- return uri
94
-
95
-
96
- def _jsonable(value: Any) -> Any:
97
- if isinstance(value, list):
98
- return [_jsonable(v) for v in value]
99
- if isinstance(value, dict):
100
- return {k: _jsonable(v) for k, v in value.items()}
101
- return value
102
-
103
-
104
- class LSP(Tool):
105
- name = "LSP"
106
- description = DESCRIPTION
107
- parameters = PARAMETERS
108
- is_readonly = True
109
-
110
- def run(self, args: dict, ctx: ToolContext) -> str:
111
- operation = args.get("operation")
112
- if operation not in OPERATIONS:
113
- return f"Error: unsupported LSP operation {operation!r}"
114
-
115
- raw_path = str(args.get("file_path", ""))
116
- if not raw_path:
117
- return "Error: file_path must not be empty"
118
- path = (
119
- os.path.realpath(os.path.abspath(os.path.join(ctx.cwd, raw_path)))
120
- if not os.path.isabs(raw_path)
121
- else os.path.realpath(raw_path)
122
- )
123
- if not os.path.isfile(path):
124
- return f"Error: File not found: {raw_path}"
125
-
126
- # workspaceSymbol is project-wide: it uses file_path only to select
127
- # the workspace/server and ignores line/character, so it skips all
128
- # position validation and coordinate conversion below.
129
- needs_position = operation != "workspaceSymbol"
130
-
131
- # Keep the agent-facing contract 1-based, while the LSP protocol is 0-based.
132
- try:
133
- line = int(args.get("line", 1))
134
- character = int(args.get("character", 1))
135
- except (TypeError, ValueError):
136
- return "Error: line and character must be integers"
137
- if needs_position and (line < 1 or character < 1):
138
- return "Error: line and character must be >= 1"
139
-
140
- # Validate the requested line before spinning up a server so a bad
141
- # position fails fast even when no LSP binary is installed.
142
- try:
143
- text = Path(path).read_text(encoding="utf-8", errors="replace")
144
- except (OSError, UnicodeError) as e:
145
- return f"Error: failed to read {raw_path}: {e}"
146
- lines = text.splitlines(keepends=True)
147
- if needs_position and line > len(lines):
148
- return f"Error: line {line} is beyond end of file ({len(lines)} lines)"
149
-
150
- try:
151
- client, _ = get_client(path, ctx.cwd, ctx.config_path)
152
- uri = Path(path).as_uri()
153
- client.open_document(uri, text)
154
- try:
155
- # LSP positions are 0-based. Character conversion to the
156
- # negotiated encoding is done here using the source line.
157
- # Skipped entirely for workspaceSymbol, which sends no position.
158
- position: dict[str, int] | None = None
159
- if needs_position:
160
- source_line = lines[line - 1].rstrip("\r\n")
161
- py_index = min(character - 1, len(source_line))
162
- if client.position_encoding == "utf-8":
163
- lsp_character = len(source_line[:py_index].encode("utf-8"))
164
- elif client.position_encoding == "utf-32":
165
- lsp_character = py_index
166
- else:
167
- lsp_character = len(source_line[:py_index].encode("utf-16-le")) // 2
168
- position = {"line": line - 1, "character": lsp_character}
169
-
170
- if operation == "goToDefinition":
171
- result = client.request(
172
- "textDocument/definition",
173
- {"textDocument": {"uri": uri}, "position": position},
174
- )
175
- elif operation == "findReferences":
176
- result = client.request(
177
- "textDocument/references",
178
- {
179
- "textDocument": {"uri": uri},
180
- "position": position,
181
- "context": {"includeDeclaration": True},
182
- },
183
- )
184
- elif operation == "hover":
185
- result = client.request(
186
- "textDocument/hover", {"textDocument": {"uri": uri}, "position": position}
187
- )
188
- elif operation == "documentSymbol":
189
- result = client.request(
190
- "textDocument/documentSymbol", {"textDocument": {"uri": uri}}
191
- )
192
- elif operation == "workspaceSymbol":
193
- result = client.request(
194
- "workspace/symbol", {"query": str(args.get("query", ""))}
195
- )
196
- elif operation == "goToImplementation":
197
- result = client.request(
198
- "textDocument/implementation",
199
- {"textDocument": {"uri": uri}, "position": position},
200
- )
201
- elif operation == "prepareCallHierarchy":
202
- result = client.request(
203
- "textDocument/prepareCallHierarchy",
204
- {"textDocument": {"uri": uri}, "position": position},
205
- )
206
- else:
207
- prepared = client.request(
208
- "textDocument/prepareCallHierarchy",
209
- {"textDocument": {"uri": uri}, "position": position},
210
- )
211
- # A spec-compliant server returns a list or null; guard
212
- # against a non-list (truthy but unsubscriptable) reply so
213
- # a misbehaving server cannot raise past the LSPError guard.
214
- if not isinstance(prepared, list) or not prepared:
215
- return "No call hierarchy item found at this position"
216
- item = prepared[0]
217
- method = (
218
- "callHierarchy/incomingCalls"
219
- if operation == "incomingCalls"
220
- else "callHierarchy/outgoingCalls"
221
- )
222
- result = client.request(method, {"item": item})
223
- finally:
224
- # Close-after-use: keeping every inspected file open would grow
225
- # the (long-lived, cached) server's document set unbounded over a
226
- # session. Best-effort — a failed close must not mask a result
227
- # or a raised LSPError.
228
- with contextlib.suppress(Exception):
229
- client.close_document(uri)
230
- except (LSPError, ValueError) as e:
231
- # LSPError: no server / server failure. ValueError: a malformed
232
- # lsp.servers entry surfacing on this lazy get_client call (it is
233
- # also validated eagerly at session start, but a Session built
234
- # outside make_session may reach here first).
235
- return f"Error: {e}"
236
-
237
- if result is None:
238
- result = []
239
- if isinstance(result, dict) and not result:
240
- return f"No results found for {operation}"
241
- if isinstance(result, list) and not result:
242
- return f"No results found for {operation}"
243
- return json.dumps(_jsonable(result), ensure_ascii=False, indent=2)