Merge remote-tracking branch 'origin/main' into perf/search-text-line-lookup-pr

Conflicts:
  src/serena/util/text_utils.py
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-09-23 14:07:36 +02:00
commit fba55b6844
133 files changed
+8889 -2040

No files matched your search

+23 -2
View File
@@ -25,18 +25,39 @@ Snapshot tests use syrupy.
# Docstrings & Comments # Docstrings & Comments
Documentation style:
* You consistently use reStructuredText. * You consistently use reStructuredText.
* You structure function implementations into functional blocks that are separated by blank lines. * You structure function implementations into functional blocks that are separated by blank lines.
Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the
block in a concise manner. block in a concise manner.
* When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase * When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences. clearly defines *what* it is. Any details then follow in subsequent sentences.
General documentation principles:
* Each piece of information appears exactly once, at the element that owns it: callers do not * Each piece of information appears exactly once, at the element that owns it: callers do not
explain callees' internals, and callees do not describe their callers. explain callees' internals, and callees do not describe their callers.
* Code changes are documented exclusively in commit messages, not in comments.
* Things you consequently avoid:
- For a function/class, you do not describe who calls/uses it. A call/usage site may explain why a usage occurs, but only if it is non-obvious.
- When a function is called, you do not describe what it does. That belongs in the function's docstring.
- You do not describe in comments how an implementation differs from a previous state of the code or why a change was made. That belongs in the commit message.
# Pull requests # REPL & facades
Read `mem:creating_pull_requests` when asked to participate in the creation of a pull request. Read `mem:repl` before working on `serena.repl` (the code-execution paradigm and its facade APIs) or on tools
delegating to it: structure, exposure/naming principles, configuration of the API scope and the availability policy.
# Commits & pull requests
* Commit messages:
* The subject line must cover the *entire* change; make it suitably abstract if necessary
* Details are presented in concise bullet items, one point per item; no prose paragraphs.
Where the change spans several topics, group the items by topic, each group with a short heading.
* Wrap all lines at ~100 characters.
* Write the message to a file and commit with `-F`; do not pass long text via `-m`.
* Read `mem:creating_pull_requests` when asked to participate in the creation of a pull request.
# Memories # Memories
+120
View File
@@ -0,0 +1,120 @@
# REPL (`serena.repl`)
Alternative interaction paradigm: one tool (`serena_repl`) executes Python code against entrypoint `s`,
whose attributes are facades (`s.lsp`, `s.edit`, `s.fs`, `s.mem`, `s.shell`, `s.jb`, `s.cfg`).
Code runs like a notebook cell (module-level exec in the session namespace); the value of a trailing
expression is the result. No `return` (a top-level `return` yields a SyntaxError with a hint).
IMPORTANT: The REPL interface is a BETA feature. If you encounter any issues, please report them
but do not submit PRs for it (except for trivial fixes); the implementation is still evolving.
## Structure
- `repl/api/*_api.py`: `FacadeApi` implementations = the single implementation of each operation.
The classic tools are thin adapters delegating to the APIs (via `*ApiMixin`); tools retain only
transport concerns (input sanitisation, diagnostics context, tool-level output shaping).
- `repl/facade.py`: `Facade` = indirection over an API instance; `FacadeMethod` (enabled flag + `FacadeMethodInfo`);
`ApiScope` = which facades/methods are enabled.
- `repl/repl.py`: `SerenaRepl` (execution, error formatting), `SerenaReplEntrypoint` (`s`, `info`).
- `repl/representable.py`: `Representable`/`Renderer`; result objects carry their rendering policy.
## Design principles
- Exposure is explicit: a method is exposed iff decorated with `@facade_method(...)`, which carries
`optional`, `beta`, `can_edit`, `corresponding_tool` (mirroring the tool markers; the tool correspondence
is recorded for optional derivation of exclusions and prompt conditions, never applied automatically).
- Naming: on result objects and non-exposed API helpers, a trailing underscore (`symbols_`, `to_dict_`)
marks members that are Serena-public but not LLM-facing.
- Facades group by *domain*, not by read vs. write; mutation is expressed via `can_edit` (read-only projects
exclude editing methods). Boundary `fs`/`edit`: files as units vs. modifying content within existing files.
- Facade descriptions describe the domain only; never list operations (the method list is always shown alongside).
- Output parameters (depth, include_body, max_answer_chars, ...) are passed at retrieval time so that the
rendering policy is fixed once and inherited by derived results.
- Results expose data to code (`.symbols`, `.occurrences`, `.lines`, ...) and render like the classic tool output.
- Progressive disclosure: a priori only facade names, descriptions and method names; `s.info("<facade>")` /
`s.info("<facade>.<method>")` give signature + docstring together, never a signature alone.
`info(*items)` documents several items at once; unknown items are reported inline.
- Disclosure tiers: tier 0 (tool description) = facades, descriptions, method names with navigable return types
(`find_symbol -> LspSymbolCollection`); tier 1 (`s.info("<facade>")`) = all common methods in full,
`niche` methods (`@facade_method(niche=True)`, rarely needed + long docs) only as summary + pointer, result
types by name only; tier 2 = types on request. Types are never pushed (`provide_info_with_facade` exists but
is set nowhere); the tool description tells the model to request type docs only when processing results in code.
- Result types: every user-defined class reachable through annotations (method parameters/returns, and the members
of reachable types, transitively) is automatically documentable (`Facade._discover_referenced_types`); builtins,
typing constructs and stdlib classes are excluded. Explicit `ReferencedType` declarations (constructor arg
`types=`) exist for curation only: an optional `members` whitelist (foreign/large classes such as
`LanguageServerSymbol`; listed methods are shown even if undocumented, convention-derived ones only if
documented) and flags. Enums render with members/values, TypedDicts with their keys. Types are documented via
`s.info("<facade>.<Type>")` or bare `s.info("<Type>")`; method docs point to their referenced types.
Result classes declare attribute annotations at class level (attributes set only in `__init__` are not
discoverable). Annotations are rendered without module paths, so signature names equal lookup names.
A type's documentation transitively includes the declared types its members reference; within a session
(`SerenaSession.described_type_names`), a contained type is documented once and afterwards only pointed to
(explicit requests always yield full documentation).
## Sessions (`serena.session`)
- MCP provides no reliable session identification (newer protocol versions drop it), and clients keep a stdio
server across conversations. Hence the REPL's session identity is LLM-supplied: `create_system_prompt` creates
a `SerenaSession` and states its id; `serena_repl` takes a required `session_id`. `SessionRegistry` creates
unknown ids on demand (benign: at worst docs are repeated) and evicts LRU and idle (TTL) sessions.
Sessions survive REPL rebuilds.
- Persistence (notebook semantics): `SerenaSession.repl_namespace` is the globals of the session's executions,
which run at module level (statements exec'd, a trailing expression eval'd; line numbers preserved), so all
top-level bindings persist across calls. `s` is re-bound in the namespace before every execution, so persisted
functions always use the current entrypoint. `s.vars()`/`s.clear()` list/remove persisted items. Data is tied to
the session's lifetime (not cleared on REPL rebuild); stored facades/project objects may go stale.
Deferred idea if memory becomes an issue: hybrid — implicit items expire after N turns, explicit store
(e.g. `s.d`) for indefinite retention.
- Only tools whose use presupposes having read the instructions may require the id. `activate_project` and
`initial_instructions` may be called first and keep the existing MCP-context-derived session handling (prompt
provision status); migrating that to LLM-supplied ids is a separate, future change.
- APIs must not import `serena.tools` at module level except for tool classes in decorators; tools import
APIs locally in `_api()` (API modules refer to tool classes).
## Configuration
- `agent_interface: tools | REPL` (`AgentInterface`; global config, overridable per project; CLI `--agent-interface`).
`None` = Serena's default (`tools`). Fixed for the session. In REPL mode the toolset is *fixed*
(`serena_repl`, `initial_instructions`, `activate_project` unless single-project); tool inclusion/exclusion
definitions do not apply — each interface has its own configuration vocabulary (tool definitions ↔ tools,
API definitions ↔ REPL). Contexts do not influence the interface.
In REPL mode, the language backend may change upon project activation (a project's backend override is
applied; background modes, facades, prompt params and backend initialisation are recomputed), whereas the
tool interface forbids this (the toolset depends on the backend and is fixed).
Idea (not implemented, considered over-engineered for now): contexts could declare *supported* interfaces
(a capability constraint, e.g. clients that handle the REPL badly), with the user's preference choosing among them.
- `included_apis`/`excluded_apis` (references `facade` or `facade.method`) in global config, context, modes,
project config; applied in that order via `ApiScope` (exclusions first, then inclusions; later definitions win).
- Opt-in rule: methods of a facade that is not included (excluded, or optional without explicit inclusion) and
optional methods are enabled only if included explicitly; all other methods are enabled unless excluded.
Facades can be optional (`Facade.from_api(..., is_optional=True)`, e.g. `ext`, mirroring optional tools);
`Facade.is_enabled()` is derived: a facade is available iff it has at least one enabled method.
- The REPL is rebuilt whenever the active tools are updated (mode switch, project activation).
## Availability policy
- Keep as much functionality as possible in the REPL; exclude nothing by default.
* Do not derive API exclusions from tool exclusions automatically (not via the `corresponding_tool`
correspondence, not via an option): contexts exclude tools mostly because the *host* provides equivalents
(`read_file`, `find_file`, `replace_content`, shell). In the REPL those reads are what makes operations
composable (read → filter → return a summary), and a host tool cannot participate in REPL code.
* The host's better-integrated edit tools (diff view, undo) are a matter of guidance in the context prompt,
not of availability: exclusions can only steer the model, never enforce anything.
* For users migrating from tool mode: prefer a startup hint listing the `excluded_apis` entries corresponding
to their own (global/project, not context/mode) tool exclusions over any automatic derivation.
- Python code can always modify the system; the REPL tool is inherently fully privileged, regardless of
facade scope or the project's `read_only` setting (which only makes Serena's own API refuse edits).
A "read-only REPL" is not feasible and must not be promised.
- External projects (`s.ext`): `list_projects()`, `project_context(name)` (a `with`-able context; not nestable).
Within it, the agent's active project is temporarily switched (`active_project_context`) and the facades are
read-only (`can_edit` methods raise). Methods marked `@facade_method(uses_project_server=True)` (all of `lsp`)
are executed in the project server via `/call_facade_method` ({facade, method, args, kwargs} as JSON, result
pickled; the server is a trusted local process) when the LSP backend is active; with JetBrains they run locally
(the IDE serves all projects). Result objects must be self-contained/picklable: renderers hold no agent (only the
default length limit), LSP results carry eagerly retrieved info and reference contexts, no lambdas in output
params. Replaces the query_project/list_queryable_projects tools in the REPL.
- Project activation (activate_project) and initial_instructions stay tool-only (activation rebuilds the REPL);
Serena's configuration/session state (config overview, dashboard; later e.g. modes) lives in the `cfg` facade.
Computed conditions (read-only project, dashboard not openable) are applied to the API scope in
`SerenaAgent.get_repl` via `exclude_editing()`/`NamedApiInclusionDefinition`, mirroring the tool side.
+35 -21
View File
@@ -1,24 +1,24 @@
# the name by which the project can be referenced within Serena/when chatting with the LLM. # the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "serena" project_name: "serena"
# list of language servers to start when using the LSP backend; choose from: # list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash # ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal # bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart elixir # csharp csharp_omnisharp cue dart deno
# elm erlang fortran fsharp gdscript # elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl # gdscript gleam go groovy haskell
# html java json julia kotlin # haxe hlsl html java json
# latex lean4 lua luau markdown # julia julia_fatou kotlin latex lean4
# matlab msl nix ocaml pascal # lua luau markdown matlab msl
# perl php php_phpactor php_phpantom powershell # nextflow nix ocaml pascal perl
# python python_jedi python_pyrefly python_ty r # php php_phpactor php_phpantom powershell python
# rego ruby ruby_solargraph rust scala # python_basedpyright python_jedi python_pyrefly python_ty qml
# scss solidity svelte swift systemverilog # r rego ruby ruby_solargraph rust
# terraform toml typescript typescript_vts vue # scala scss solidity svelte swift
# yaml zig # systemverilog terraform toml typescript typescript_vts
# vue wolfram yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py; # (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of Language enum here: # For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note: # Note:
@@ -26,6 +26,7 @@ project_name: "serena"
# - For JavaScript, use typescript # - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) # - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) # - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal # - For Free Pascal/Lazarus, use pascal
# Special requirements: # Special requirements:
@@ -35,8 +36,8 @@ project_name: "serena"
# The first language server is the default language and the respective language server will be used as a fallback. # The first language server is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
language_servers: language_servers:
- python - python
- typescript - typescript
# whether to use project's .gitignore files to ignore files # whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true ignore_all_files_in_gitignore: true
@@ -68,9 +69,8 @@ excluded_tools: []
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html # Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: [] included_optional_tools: []
# initial prompt for the project, which will be provided to the LLM upon project activation # initial prompt for the project. It will always be given to the LLM upon activating the project
# (or within Serena's initial instructions if the project is activated at startup). # (contrary to the memories, which are loaded on demand).
## See: https://oraios.github.io/serena/02-usage/050_configuration.html#prompt-templates
initial_prompt: | initial_prompt: |
{{ embed_memory("critical_info") }} {{ embed_memory("critical_info") }}
@@ -177,3 +177,17 @@ ls_workspace_folders:
# - ../sibling-package # - ../sibling-package
# - ../shared-lib # - ../shared-lib
ls_additional_workspace_folders: [] ls_additional_workspace_folders: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.get_diagnostics_for_symbol") to include in the REPL
# that would otherwise be disabled (particularly optional methods, which are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_apis: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to exclude from the REPL.
# This extends the existing exclusions (e.g. from the global configuration).
excluded_apis: []
# The interface through which the agent (LLM) accesses Serena's functionality (overrides the global setting).
# Valid values: tools, REPL (see the global configuration for details); leave empty to use the global setting.
# Note: the interface is fixed at startup. If a project is activated post-init, its setting is not applied.
agent_interface:
+78 -2
View File
@@ -12,7 +12,16 @@ Status of the `main` branch. Changes prior to the next official version change w
see `CONTRIBUTING.md` see `CONTRIBUTING.md`
* General: * General:
- Fix: MCP `initialize` now reports Serena's version instead of the installed mcp SDK version (#1889) - **Major**: Add the Serena REPL as a new agent interface, reducing the tool set to a minimum and providing
a general code execution environment for all Serena operations.
This has several significant advantages over regular tool executions.
Please refer to our [documentation](https://oraios.github.io/serena/01-about/035_tools.html) for details.
- Add `auth_secret` to `serena_config.yml` for authenticating communication between Serena components
and services. When missing, null, or empty, a random UUID is generated and persisted; existing values
are preserved
- Fix: MCP server now reports Serena's version instead of the installed MCP SDK version (#1889)
- Fix: importing Serena no longer loads the `anthropic` package unless the Anthropic token counter is
actually used; the unconditional import added seconds to CLI/MCP startup on some machines (#2012)
- Fix: Parallel agents auto-registering projects could overwrite each other's changes to the global - Fix: Parallel agents auto-registering projects could overwrite each other's changes to the global
project list in `serena_config.yml` project list in `serena_config.yml`
- Perf: `search_for_pattern` resolved each match's line number by rescanning the file from the - Perf: `search_for_pattern` resolved each match's line number by rescanning the file from the
@@ -24,23 +33,56 @@ Status of the `main` branch. Changes prior to the next official version change w
- Fix: process-tree cleanup signaled descendant language-server processes without waiting for them, - Fix: process-tree cleanup signaled descendant language-server processes without waiting for them,
which could leave grandchildren as zombies; cleanup now waits for the discovered descendants (#1464) which could leave grandchildren as zombies; cleanup now waits for the discovered descendants (#1464)
- Fix: `read_only` restriction in project definition was not applied to base tool set when in single-project context (#1938) - Fix: `read_only` restriction in project definition was not applied to base tool set when in single-project context (#1938)
- Fix: `SerenaConfig.project_names` / `project_paths` were cached and never invalidated after
projects were added or removed mid-session, so user-facing project lists and error messages
stayed stale; the lists are no longer cached
- Docs: `trusted_project_path_patterns` now documents how to trust a single project. Trust is decided by - Docs: `trusted_project_path_patterns` now documents how to trust a single project. Trust is decided by
the project's root path, so a `<project root>/**` entry matches only paths below the root and therefore the project's root path, so a `<project root>/**` entry matches only paths below the root and therefore
trusts no project at all; the template now shows the bare root form alongside the parent-directory trusts no project at all; the template now shows the bare root form alongside the parent-directory
glob (#2001) glob (#2001)
- Session IDs are now created and tracked internally by Serena instead of being derived from the
MCP session, since the MCP SDK v2 no longer provides session identifiers and client session usage
was inconsistent anyway. Tools that need a session id (e.g. `activate_project`, the REPL tool) now
take it as an explicit parameter, obtained from `initial_instructions`
- Performance: `Project.gather_source_files` transitively re-derived from the filesystem, for every path,
whether that path was a file or a directory; related methods/functions now receive the information
as a parameter where it is already known (#2077)
* CLI: * CLI:
- Fix: `project health-check` reported `Health check passed - All tools working correctly` and - Fix: `project health-check` reported `Health check passed - All tools working correctly` and
exited 0 even when `FindReferencingSymbolsTool` had raised, because that failure was logged as exited 0 even when `FindReferencingSymbolsTool` had raised, because that failure was logged as
a warning while the verdict checked `FindSymbolTool` only. A reference-search failure now fails a warning while the verdict checked `FindSymbolTool` only. A reference-search failure now fails
the check; a symbol with no references is still a pass the check; a symbol with no references is still a pass
- Add `project remove`, which unregisters a project from the project list in `serena_config.yml`,
addressed either by name or by path. Only the registry entry is removed; the project's own files,
including its project configuration, are left untouched (#2029)
* Tools:
- Fix: `$!N` backreferences in regex-mode replacements expanded to the literal template text
(e.g. `EA_INPUT$!1(...)`) when the referenced group existed but did not participate in the
match (e.g. a group inside an optional construct that was skipped); unmatched groups now expand
to the empty string, and a reference to a group that the search expression does not define
raises a clear error instead of a raw `IndexError`. In literal mode, the replacement is now
used verbatim (`$!N` sequences need no escaping) instead of failing with a backreference error
- Fix: the file-editing tools saved the edited file with `open(path, "w")`, which truncates it
before the new content is complete, so a crash, an OOM kill or a full disk partway through the
write could leave a source file empty or half-written. Saves now go through the same atomic
temp-file-plus-`os.replace` helper that the memory writes already use. The helper resolves
symlinks first, so a symlinked file is still written through to its target rather than being
replaced by a regular file (#1958)
* Memories: * Memories:
- Fix: `move_memory` / rename only checked write access on the destination name, so a tool-context
rename could relocate a read-only memory; both source and destination are now checked
- Fix: `save_memory`/`edit_memory` wrote directly to the memory file with `open(path, "w")`, which - Fix: `save_memory`/`edit_memory` wrote directly to the memory file with `open(path, "w")`, which
truncates it before the new content is written; a crash, OOM kill, or full disk partway through truncates it before the new content is written; a crash, OOM kill, or full disk partway through
the write could destroy the previous, valid content instead of just losing the update. Both now the write could destroy the previous, valid content instead of just losing the update. Both now
write through a temp-file-plus-`os.replace` helper, matching the approach `save_yaml()` already write through a temp-file-plus-`os.replace` helper, matching the approach `save_yaml()` already
uses for settings files (#1958) uses for settings files (#1958)
- Fix: renaming a memory through the `rename_memory` tool raised `PermissionError` when another memory
marked read-only by `read_only_memory_patterns` referenced it, after the rename had already been
applied, leaving the memory graph half-updated; reference propagation in tool contexts now covers
only writable memories, as documented, while the CLI still propagates into read-only ones
* JetBrains: * JetBrains:
- Fix: Concurrent Serena sessions activating different projects at the same time with - Fix: Concurrent Serena sessions activating different projects at the same time with
@@ -54,7 +96,35 @@ Status of the `main` branch. Changes prior to the next official version change w
successful Serena call. Add a `serena-hooks reset` command and a `PostToolUse` example matched to successful Serena call. Add a `serena-hooks reset` command and a `PostToolUse` example matched to
Serena's own tools to close the gap (#1852) Serena's own tools to close the gap (#1852)
* Dashboard:
- Fix: DashboardManager's unsupported-mode fallback warning logged the literal text
`{fallback_mode.value}` because only the first string fragment was an f-string
- Fix: On macOS, the tray manager refreshed the tray menu straight from the Flask request handlers
for `/register`, `/update_project` and `/unregister` and from the alive-check thread. That reaches
`NSStatusItem.setMenu_()` off the main thread, which AppKit forbids and which recent macOS
versions punish with SIGTRAP, so the tray-manager process died within seconds of every agent
start and the tray icon never became usable. Menu refreshes are now marshalled onto the main
thread (#2038)
* Language Servers: * Language Servers:
- Fix: Dart analysis server no longer receives rootUri/rootPath, which added the monorepo root as an extra analysis root and could pin a CPU core at idle (#2045)
- Fix: The C# language server opened every `.csproj` found anywhere under the repository root,
without consulting the project's ignore settings. On repositories that vendor third-party or
sample C# projects, this loads projects the server cannot restore on every start, and their
restore failures bury the diagnostics of the projects the user actually works on. Project
discovery now skips `.csproj` files matched by the project's ignore patterns
- Kotlin: update the managed Kotlin LSP from `262.9593.0` to `263.4702.0`; the `262.9593.0` build
has expired and fails on startup with "This build of intellij-server has expired" (#2008)
- Fix: Godot's GDScript parser can report a symbol's end column one column past the
line-end convention every other language server follows (closing a node's range from
the next lookahead token instead of the last consumed one, when that lookahead is a
synthesized newline); `replace_symbol_body` on the last function in a file silently
consumed the separating blank line as a result. `GodotLanguageServer` now corrects this
specific, measured overshoot when building its high-level document symbols (#1974)
- Fix: High-level document symbol cache was not invalidated when the LS-specific low-level result
version changed
- Fix: A language server's cache directory was determined by the language_id rather than
the language server identifier's key. The two identifiers coincided in most cases.
- Fix: TypeScript and VTS now disable automatic type acquisition as intended, while VTS - Fix: TypeScript and VTS now disable automatic type acquisition as intended, while VTS
preserves explicit user settings across initialization and configuration requests (#1989) preserves explicit user settings across initialization and configuration requests (#1989)
VTS initialization options now override defaults per top-level key rather than replacing the VTS initialization options now override defaults per top-level key rather than replacing the
@@ -67,6 +137,10 @@ Status of the `main` branch. Changes prior to the next official version change w
its global state under ``~/Library``; Serena now gives the child process an isolated home-directory view its global state under ``~/Library``; Serena now gives the child process an isolated home-directory view
via ``solidity_state_dir`` without changing the parent process's ``HOME`` (#1817) via ``solidity_state_dir`` without changing the parent process's ``HOME`` (#1817)
- Add Fatou support as an alternative Julia language server (`julia_fatou`) - Add Fatou support as an alternative Julia language server (`julia_fatou`)
- Fix: C# properties/fields whose type contains a literal `(`, e.g. a tuple type like
`(int X, string Y)`, had their name corrupted to include a trailing `:` because the
parenthesis in the type was mistaken for a method's parameter list; `find_symbol` on
the real name then returned nothing
- Fix: Nextflow's `_flush_deferred_workspace_scan` marked the workspace scan flushed even when both - Fix: Nextflow's `_flush_deferred_workspace_scan` marked the workspace scan flushed even when both
of its `completion` probes failed, permanently skipping the flush (and silencing retries) for the of its `completion` probes failed, permanently skipping the flush (and silencing retries) for the
rest of the session (#1871) rest of the session (#1871)
@@ -111,7 +185,10 @@ CLI:
- Fix `project index-file` command not using only the relevant language server to index the given file (#1965) - Fix `project index-file` command not using only the relevant language server to index the given file (#1965)
* Dependencies: * Dependencies:
- Fix: declare `click` as a direct dependency; all three console scripts (`serena`, `serena-agent`,
`serena-hooks`) import it but it was only available transitively
- Remove the redundant `dotenv` dependency; the `dotenv` module is provided by `python-dotenv` - Remove the redundant `dotenv` dependency; the `dotenv` module is provided by `python-dotenv`
- Upgrade the `mcp` SDK from 1.28.1 to 2.2.0
# v1.7.0 (2026-08-09) # v1.7.0 (2026-08-09)
@@ -264,7 +341,6 @@ CLI:
`target_file`/`targetFile` file-path keys (shared payload parsing, applies to all hook clients). `target_file`/`targetFile` file-path keys (shared payload parsing, applies to all hook clients).
- Fix hook input parsing for clients that emit raw control characters in JSON string values #1743. - Fix hook input parsing for clients that emit raw control characters in JSON string values #1743.
# v1.6.1 (2026-07-21) # v1.6.1 (2026-07-21)
* General: * General:
+5 -1
View File
@@ -11,6 +11,9 @@ The following types of contributions can be submitted directly via pull requests
For other changes, please open an issue first to discuss your ideas with the maintainers. For other changes, please open an issue first to discuss your ideas with the maintainers.
Do not submit pull requests for beta features (unless they are trivial bug fixes); instead, provide feedback via issues or discussions.
At present, the Serena REPL is a beta feature.
## Licensing and Contributor License Agreement (CLA) ## Licensing and Contributor License Agreement (CLA)
Serena is multi-licensed by component (see [LICENSE](LICENSE)): Serena is multi-licensed by component (see [LICENSE](LICENSE)):
@@ -44,7 +47,8 @@ See the corresponding [memory](.serena/memories/adding_new_language_support_guid
## Submitting Pull Requests ## Submitting Pull Requests
Before submitting a PR, be sure to document your relevant changes (i.e. new features, fixes) in `CHANGELOG.md`. Before submitting a PR, be sure to document your relevant changes (i.e. new features, fixes) in `CHANGELOG.md`;
documentation changes should not be included.
Use a concise style and add your change to the appropriate section Use a concise style and add your change to the appropriate section
("Language Servers", "Tools", "JetBrains", "CLI", "Memories", "Dashboard", "Hooks", "General", "Security"). ("Language Servers", "Tools", "JetBrains", "CLI", "Memories", "Dashboard", "Hooks", "General", "Security").
+27 -6
View File
@@ -8,12 +8,18 @@ and tools for formatting and type checking.
## Release Process ## Release Process
1. Ensure clean git status. 1. Ensure clean git status.
2. Set the version for release, e.g. 2. Set the version for release. Normally, the version to be released is the one already reserved by the
current `.dev0` version (e.g. `1.8.0` when the repository is at `1.8.0.dev0`):
python scripts/bump_version.py --patch
python scripts/bump_version.py --minor
This also creates the git tag. python scripts/bump_version.py release current
To release a version beyond the reserved one, name the part to bump instead, e.g.
python scripts/bump_version.py release patch
python scripts/bump_version.py release minor
This updates `CHANGELOG.md`, commits the release version, creates the git tag, and then
commits the subsequent `.dev0` version for the next iteration.
3. Push to GitHub: 3. Push to GitHub:
git push git push
@@ -26,4 +32,19 @@ and tools for formatting and type checking.
[GitHub Releases page](https://github.com/oraios/serena/releases). [GitHub Releases page](https://github.com/oraios/serena/releases).
When ready, publish it (click *Publish release*). When ready, publish it (click *Publish release*).
This triggers the `publish` workflow, which builds and publishes the This triggers the `publish` workflow, which builds and publishes the
package to PyPI. package to PyPI.
### Bumping the Development Version
Independently of a release, the development version can be bumped, e.g. when work on `main`
begins to target a new minor or major version:
python scripts/bump_version.py dev minor
python scripts/bump_version.py dev major
This sets the version to the respective new `.dev0` version (e.g. `1.8.0.dev0`) and commits it as
"Set version to vX"; it creates no tag and does not modify `CHANGELOG.md`.
The subsequent release of that version is then performed with `release current`.
Both commands require a clean git status and support `--dry-run` to preview the changes.
+2 -2
View File
@@ -42,7 +42,7 @@ Some languages require additional installations or setup steps, as noted.
subsumes `typescript` and `html` for `.ts`/`.html` files, so do not also list those) subsumes `typescript` and `html` for `.ts`/`.html` files, so do not also list those)
* **Ansible** * **Ansible**
(experimental; requires Node.js and npm; automatically installs `@ansible/ansible-language-server`; (experimental; requires Node.js and npm; automatically installs `@ansible/ansible-language-server`;
must be explicitly specified in the `languages` entry in the `project.yml`; requires `ansible` in PATH for full functionality) must be explicitly specified in the `language_servers` entry in the `project.yml`; requires `ansible` in PATH for full functionality;
the upstream `@ansible/[email protected]` supports hover, completion, definition, the upstream `@ansible/[email protected]` supports hover, completion, definition,
semantic tokens, and validation; document symbols, workspace symbols, references, and rename semantic tokens, and validation; document symbols, workspace symbols, references, and rename
are not supported by this version) are not supported by this version)
@@ -51,7 +51,7 @@ Some languages require additional installations or setup steps, as noted.
(requires Java 21+ on PATH; uses [bsl-language-server](https://github.com/1c-syntax/bsl-language-server) by 1c-syntax; the JAR is auto-downloaded and SHA-256-verified for the bundled default version; supports `.bsl` and `.os` files; configure optional `ls_path` or `bsl_ls_version` under `ls_specific_settings.bsl`) (requires Java 21+ on PATH; uses [bsl-language-server](https://github.com/1c-syntax/bsl-language-server) by 1c-syntax; the JAR is auto-downloaded and SHA-256-verified for the bundled default version; supports `.bsl` and `.os` files; configure optional `ls_path` or `bsl_ls_version` under `ls_specific_settings.bsl`)
* **C#** * **C#**
(by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)); (by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5));
set language to `csharp_omnisharp` to use OmiSharp instead) set language to `csharp_omnisharp` to use OmniSharp instead)
* **C/C++** * **C/C++**
(by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`);
for best results, provide a `compile_commands.json` at the repository root; for best results, provide a `compile_commands.json` at the repository root;
+17
View File
@@ -0,0 +1,17 @@
(privacy)=
# Privacy Policy
Serena respects your privacy and is committed to protecting your personal information.
When using Serena, no personal data or data about the project being worked on is sent to any external servers.
We collect only the following anonymous usage data whenever Serena is started:
* the version of Serena being used,
* the operating system being used,
* the language backend being used,
* the enabled status of the Serena Dashboard,
* the enabled [Serena agent context](contexts)
This data is collected strictly to help us understand Serena usage.
If you want to opt out of usage data reporting, set the environment variable `SERENA_USAGE_REPORTING` to `false`.
+2 -2
View File
@@ -124,8 +124,8 @@ When using Serena, we highly recommend that you start CC as
claude --system-prompt="$(serena prompts print-cc-system-prompt-override)" claude --system-prompt="$(serena prompts print-cc-system-prompt-override)"
``` ```
You can also consider adding the content of `serena cc-system-prompt-override` to your `CLAUDE.md` files, You can also consider adding the content of `serena prompts print-cc-system-prompt-override` to your `CLAUDE.md` files,
but the effect be insufficient for counteracting Claude Code's bias towards internal tools. but the effect may be insufficient for counteracting Claude Code's bias towards internal tools.
::: :::
**Global Configuration**. To add the Serena MCP server for all your projects, use the user-level configuration of claude code and the `--project-from-cwd` flag: **Global Configuration**. To add the Serena MCP server for all your projects, use the user-level configuration of claude code and the `--project-from-cwd` flag:
+3 -1
View File
@@ -80,7 +80,9 @@ This convention has two practical consequences:
- **Renames keep references intact.** When you rename or move a memory with the `rename_memory` - **Renames keep references intact.** When you rename or move a memory with the `rename_memory`
tool, Serena rewrites every `` `mem:OLD_NAME` `` occurrence across all memories to point to tool, Serena rewrites every `` `mem:OLD_NAME` `` occurrence across all memories to point to
the new name. References that do not use the `mem:` prefix will not be updated automatically. the new name, except in memories matched by `read_only_memory_patterns`, which the agent cannot
write; `serena memories check` reports such a reference as stale.
References that do not use the `mem:` prefix will not be updated automatically.
- **Integrity checks** (see [below](memory-cli)) report any `` `mem:NAME` `` whose target does - **Integrity checks** (see [below](memory-cli)) report any `` `mem:NAME` `` whose target does
not resolve to an existing memory, and propose similarly-named candidates as likely intended not resolve to an existing memory, and propose similarly-named candidates as likely intended
targets. targets.
+51 -9
View File
@@ -26,7 +26,7 @@ Some of the configurable settings include:
* the language backend to use by default (i.e., the JetBrains plugin or language servers); * the language backend to use by default (i.e., the JetBrains plugin or language servers);
this can also be [overridden per project](per-project-language-backend) this can also be [overridden per project](per-project-language-backend)
* UI settings affecting the [Serena Dashboard and GUI tool](060_dashboard.md) * UI settings affecting the [Serena Dashboard and GUI tool](060_dashboard.md)
* the set of tools to enable/disable by default * the set of tools or REPL API functions to enable/disable by default
* the set of [modes](modes) to use by default * the set of [modes](modes) to use by default
* tool execution parameters (timeout, max. answer length) * tool execution parameters (timeout, max. answer length)
* global ignore rules * global ignore rules
@@ -55,6 +55,37 @@ You can access it
```shell ```shell
serena config edit serena config edit
``` ```
(agent-interfaces)=
### Agent Interfaces
Serena provides its functionality to the agent (LLM) through one of two interfaces
(see [Tools and APIs](../01-about/035_tools) for the operations they offer):
* **tools**: every operation is a separate tool of the MCP server.
* **REPL** (new in Serena v2): a single tool executes Python code, through which the agent accesses the operations
programmatically, being able to combine several of them in one call.
The interface is selected via the `agent_interface` setting in the global configuration.
It can be overridden in the project configuration or via the `--agent-interface` command-line option,
and it is fixed for the duration of a session.
The two interfaces are configured differently:
* With the **tool interface**, the set of tools results from the tool inclusion/exclusion settings
(`excluded_tools`, `included_optional_tools`, `fixed_tools`) of the global configuration, the context,
the modes and the project configuration.
* With the **REPL interface**, the set of tools is fixed (the REPL tool and the tools which have no
counterpart within the REPL, e.g. for project activation); the tool settings above consequently do not apply.
The operations available *within* the REPL are configured via `included_apis`/`excluded_apis` instead,
which are supported in the same configuration layers and reference either a group of operations
(e.g. `lsp`) or an individual operation (e.g. `lsp.find_symbol`).
```{note}
Restricting the operations available in the REPL is a means of steering the agent, not a security mechanism:
the Python code that is executed can, in principle, do anything the Serena process can do.
See [Security](070_security) for isolation options.
```
## Modes and Contexts ## Modes and Contexts
@@ -238,7 +269,7 @@ This ensures backward compatibility: existing projects that already have a `.ser
Most users will not need to adjust these settings. Most users will not need to adjust these settings.
::: :::
Under the key `ls_specific_settings` in `serena_config.yml`, you can you pass global per-language, Under the key `ls_specific_settings` in `serena_config.yml`, you can pass global per-language,
language server-specific configuration. language server-specific configuration.
You can use the same key in the project configuration files (`project.yml` You can use the same key in the project configuration files (`project.yml`
@@ -796,10 +827,10 @@ Supported settings:
| Setting | Default | Description | | Setting | Default | Description |
|---|---|---| |---|---|---|
| `ls_path` | managed download | Override the Kotlin Language Server executable path. | | `ls_path` | managed download | Override the Kotlin Language Server executable path. |
| `kotlin_lsp_version` | `262.9593.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. | | `kotlin_lsp_version` | `263.4702.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. |
| `jvm_options` | `-Xmx2G` | Value assigned to `JAVA_TOOL_OPTIONS` for the Kotlin LS process. Set to `""` to disable JVM options entirely. | | `jvm_options` | `-Xmx2G` | Value assigned to `JAVA_TOOL_OPTIONS` for the Kotlin LS process. Set to `""` to disable JVM options entirely. |
The managed `262.9593.0` packages include a bundled JBR. For a custom `ls_path`, point directly to The managed `263.4702.0` packages include a bundled JBR. For a custom `ls_path`, point directly to
`bin/intellij-server` (`bin/intellij-server.exe` on Windows). Serena also retains the legacy download `bin/intellij-server` (`bin/intellij-server.exe` on Windows). Serena also retains the legacy download
layout for custom Kotlin LSP versions older than `262.4739.0`. The pinned current and frozen initial layout for custom Kotlin LSP versions older than `262.4739.0`. The pinned current and frozen initial
releases are checksum-verified; arbitrary custom versions are downloaded without checksum verification. releases are checksum-verified; arbitrary custom versions are downloaded without checksum verification.
@@ -809,7 +840,7 @@ Example:
```yaml ```yaml
ls_specific_settings: ls_specific_settings:
kotlin: kotlin:
kotlin_lsp_version: "262.9593.0" kotlin_lsp_version: "263.4702.0"
jvm_options: "-Xmx4G -XX:+UseG1GC" jvm_options: "-Xmx4G -XX:+UseG1GC"
``` ```
@@ -1225,6 +1256,19 @@ Supported settings:
| `server_ready_timeout` | `10.0` | Timeout in seconds for waiting on the server-ready signal after initialization. If the signal does not arrive within this window, Serena logs a message and proceeds anyway. | | `server_ready_timeout` | `10.0` | Timeout in seconds for waiting on the server-ready signal after initialization. If the signal does not arrive within this window, Serena logs a message and proceeds anyway. |
| `indexing_start_grace` | `5.0` | Timeout in seconds to wait for tsserver to *start* reporting `$/progress` before the first cross-file reference query. tsserver must resolve the project graph before it can emit the first progress token, and that can take longer than the default on a very large project; if it takes longer than this window, Serena assumes no indexing was needed and may return incomplete cross-file references. Raising `indexing_timeout` alone does not help here, since this grace elapses first. Increase this for very large projects if `find_referencing_symbols`/`request_references` returns incomplete results shortly after project load. | | `indexing_start_grace` | `5.0` | Timeout in seconds to wait for tsserver to *start* reporting `$/progress` before the first cross-file reference query. tsserver must resolve the project graph before it can emit the first progress token, and that can take longer than the default on a very large project; if it takes longer than this window, Serena assumes no indexing was needed and may return incomplete cross-file references. Raising `indexing_timeout` alone does not help here, since this grace elapses first. Increase this for very large projects if `find_referencing_symbols`/`request_references` returns incomplete results shortly after project load. |
##### TypeScript monorepos and cross-package references
In a monorepo, `find_referencing_symbols` / `find_references` only include consumers in other packages when tsserver can walk from a package's declaration file back to its sources. That walk requires [TypeScript project references](https://www.typescriptlang.org/docs/handbook/project-references.html) (`composite` + `references`), not merely a solution-style root `tsconfig.json` or `package.json` `exports`.
Without those edges, results are **silently partial**: a symbol may show only same-package references (or none) even though other packages import it. This is tsserver behaviour Serena inherits, not a Serena bug ([microsoft/TypeScript#30823](https://github.com/microsoft/TypeScript/issues/30823); oraios/serena#1939).
What to do in a TypeScript monorepo:
- Declare `composite: true` in each library package's `tsconfig.json` and list dependent projects under `references` in the consumer (or a solution-style root).
- Prefer source imports (or generate declaration maps) so tsserver can map `dist/*.d.ts` back to sources.
- After changing the project graph, restart Serena (or the TypeScript language server) so tsserver rebuilds the program.
- If cross-package references still look short, verify with grep before treating the LSP answer as complete; same-package results being complete does not imply the package boundary was crossed.
#### Svelte #### Svelte
Serena uses `svelte-language-server` for the `svelte` language key. Use `svelte` for Svelte projects instead of also listing `typescript`, unless you intentionally want multiple language servers active for the same files. Serena uses `svelte-language-server` for the `svelte` language key. Use `svelte` for Svelte projects instead of also listing `typescript`, unless you intentionally want multiple language servers active for the same files.
@@ -1334,8 +1378,6 @@ It is advisable to use the default prompt as a starting point and modify it to s
### Usage Reporting ### Usage Reporting
On startup, Serena reports anonymous usage data to help us understand Serena usage. On startup, Serena reports anonymous usage data to help us understand Serena usage, as explained in our [privacy policy](privacy).
Specifically, we collect the Serena version, the operating system & language backend being used as well as the dashboard enabled status.
No personally identifiable information or project-specific information is collected.
If you want to opt out of usage reporting, set the environment variable `SERENA_USAGE_REPORTING` to `false`. If you want to opt out of usage data reporting, set the environment variable `SERENA_USAGE_REPORTING` to `false`.
+28 -2
View File
@@ -23,18 +23,22 @@ However, reports which amount to noting that Serena's tools can execute commands
describe intended functionality rather than vulnerabilities, and we will reject advisories that fail to recognise this describe intended functionality rather than vulnerabilities, and we will reject advisories that fail to recognise this
or otherwise ignore the above assumptions. or otherwise ignore the above assumptions.
Sandboxing is the *only* way to fully protect against unintended consequences when using coding agents; Sandboxing is the *only* way to fully protect against unintended consequences when using coding agents;
constraints on the tools themselves cannot achieve this and are therefore not an approach we pursue. constraints on the tools or REPL APIs cannot achieve this and are therefore not an approach we pursue.
::: :::
## General Recommendations for Risk Reduction ## General Recommendations for Risk Reduction
To reduce the risk of unintended consequences, we recommend that you: To reduce the risk of unintended consequences, we recommend that you:
- back up your work regularly (keep the project being worked on under version control), - back up your work regularly (keep the project being worked on under version control),
- restrict the set of allowed tools via the [configuration](050_configuration), - restrict the set of allowed tools via the [configuration](050_configuration)
(note that restrictions are effective for the tool interface only, see [below](repl-security)),
- do not expose [Serena's network services](network-security) to untrusted networks. - do not expose [Serena's network services](network-security) to untrusted networks.
If you do not fully trust the client/the LLM, we additionally recommend to monitor tool executions carefully If you do not fully trust the client/the LLM, we additionally recommend to monitor tool executions carefully
(provided that your MCP client supports this). (provided that your MCP client supports this).
Note that with the REPL interface, such monitoring is necessarily coarser: every action appears as the same tool
being called, and the client cannot tell a read-only call from a modifying one by the tool's name alone.
What needs to be reviewed is the submitted code.
(sandboxing)= (sandboxing)=
## Sandboxing ## Sandboxing
@@ -97,6 +101,28 @@ the introduction of this setting retain a pattern that trusts all projects, ensu
not broken, whereas newly created configurations trust no project by default. not broken, whereas newly created configurations trust no project by default.
The applicable value can be inspected in the dashboard. The applicable value can be inspected in the dashboard.
(repl-security)=
## The REPL Interface
With the [REPL interface](agent-interfaces), the agent does not invoke individual tools; it submits Python code,
which Serena executes.
In the default configuration, Serena is equally capable either way, as shell execution and file modification are
available in both interfaces.
The difference lies in what restrictions can achieve:
- With the **tool interface**, excluding a tool removes the respective capability: a tool that is not exposed
cannot be invoked, so forbidding shell execution or file modification is effective.
- With the **REPL interface**, there is no such guarantee.
Restricting the available operations (`included_apis`/`excluded_apis`) steers the agent towards the intended
way of working, but the submitted code can, in principle, do anything the Serena process can do — irrespective
of the operations Serena itself provides.
Excluding shell execution, for example, does not prevent the code from achieving the same effect by other means.
- The `read_only` project setting is subject to the same limitation: Serena's own editing operations are refused,
yet code executed in the REPL is not prevented from modifying files.
The assumptions stated above therefore apply unchanged, but if you require actual constraints rather than
guidance, [sandboxing](sandboxing) is the answer.
(network-security)= (network-security)=
## Network Security ## Network Security
+4 -2
View File
@@ -40,8 +40,10 @@ You can customize this location via project settings:
```yaml ```yaml
# .serena/project.yml # .serena/project.yml
language_servers: language_servers:
- cpp
ls_specific_settings:
cpp: cpp:
compile_commands_dir: custom/rel/path (defaults to .serena) compile_commands_dir: custom/rel/path # defaults to .serena
``` ```
### With ccls ### With ccls
@@ -76,7 +78,7 @@ choco install ccls
#### Configuration #### Configuration
After installing ccls, configure Serena to use it via project settings (in `.serena/project.yml`) After installing ccls, configure Serena to use it via project settings (in `.serena/project.yml`)
by adding `cpp_ccls` to the `languages` list. Replace `cpp` with `cpp_ccls` if you already have the `cpp` entry. by adding `cpp_ccls` to the `language_servers` list. Replace `cpp` with `cpp_ccls` if you already have the `cpp` entry.
ccls can handle relative paths in `compile_commands.json`, so no transformation is necessary ccls can handle relative paths in `compile_commands.json`, so no transformation is necessary
and no transformed `compile_commands.json` file will be created. and no transformed `compile_commands.json` file will be created.
+104 -22
View File
@@ -150,30 +150,112 @@ def autogen_tool_list(target_filename = "01-about/035_tools.md"):
from serena.tools import ToolRegistry from serena.tools import ToolRegistry
target_file = Path(__file__).parent / target_filename target_file = Path(__file__).parent / target_filename
with open(target_file, "w") as f: with open(target_file, "w", encoding="utf-8") as f:
f.write("<!-- This file is auto-generated by docs/autogen_docs.py. Do not edit it manually. -->\n\n") f.write("<!-- This file is auto-generated by docs/autogen_docs.py. Do not edit it manually. -->\n\n")
f.write("# Tools\n\n") f.write("# Tools and APIs\n\n")
f.write("Find the full list of Serena's tools below.\n\n") f.write(
f.write("Note that in most configurations, only a subset of these tools will be enabled simultaneously.\n") "Serena provides an agent (LLM) with its functionality through one of two interfaces (configured in Serena's [global configuration](global-config)):\n\n"
f.write("Tools marked as *optional* are disabled by default.\n\n") "* **Tools** (the classic interface): every operation is a separate tool of the MCP server.\n"
f.write("Tools marked as [BETA] were recently introduced and may not be fully robust yet.\n\n") "* **REPL** (new in Serena v2): a single tool executes Python code, through which the agent accesses the operations\n"
tools_by_module = ToolRegistry().get_registered_tools_by_module() " programmatically. The agent can thus combine several operations in one call, process the results\n"
priority_modules = {"serena.tools.symbol_tools": 1, "serena.tools.jetbrains_tools": 2} " in code and return only the information it actually needs.\n\n"
"While both interfaces offer the same general functionality for the most part, "
"the REPL interface addresses several limitations inherent in the tool-based approach (see [advantages](repl-advantages) below).\n\n"
)
f.write("\n\n:::{note}\nThe Serena REPL is an unreleased BETA feature. Please provide feedback; if you encounter issues, report them.\n:::\n\n")
text = TextBuilder()
sorted_modules = sorted(tools_by_module.keys(), key=lambda m: (priority_modules.get(m, 3), m)) def tools_section():
for module in sorted_modules: f.write("## Tools (Classic Interface)\n\n")
tools = tools_by_module[module] f.write("Find the full list of Serena's tools below.\n\n")
module = module.replace("serena.tools.", "") f.write("Note that in most configurations, only a subset of these tools will be enabled simultaneously.\n")
text.with_line(f"* **{module}**") f.write("Tools marked as *optional* are disabled by default.\n\n")
for tool in tools: f.write("Tools marked as [BETA] were recently introduced and may not be fully robust yet.\n\n")
info = "" tools_by_module = ToolRegistry().get_registered_tools_by_module()
if tool.is_optional: priority_modules = {"serena.tools.symbol_tools": 1, "serena.tools.jetbrains_tools": 2}
info += " *(optional)*"
if tool.is_beta: text = TextBuilder()
info += " [BETA]" sorted_modules = sorted(tools_by_module.keys(), key=lambda m: (priority_modules.get(m, 3), m))
text.with_line(f"* `{tool.tool_name}`{info}: {tool.class_docstring}", indent=2) for module in sorted_modules:
f.write(text.build()) tools = tools_by_module[module]
module = module.replace("serena.tools.", "")
text.with_line(f"* **{module}**")
for tool in tools:
info = ""
if tool.is_optional:
info += " *(optional)*"
if tool.is_beta:
info += " [BETA]"
text.with_line(f"* `{tool.tool_name}`{info}: {tool.class_docstring}", indent=2)
f.write(text.build())
def facades_section():
from serena.repl.facade import ApiScope
from serena.agent import SerenaAgent, SerenaConfig
from serena.language_backend import BuiltinLanguageBackend
f.write("\n\n## Serena's REPL (Code Execution-Based Interface)\n\n")
f.write(
"With the REPL interface, the agent uses a single tool, which executes Python code. The code accesses\n"
"Serena's functionality through the entrypoint object `s`, whose attributes are *facades*, each of which\n"
"groups the operations of one domain. For instance, this code finds a class and returns the names of its\n"
"members in a single call:\n\n"
"```python\n"
'result = s.lsp.find_symbol("SerenaAgent", depth=1)\n'
"[member.name for member in result.symbols[0].iter_children()]\n"
"```\n\n"
"The agent retrieves the details it requires (signatures, documentation, result types) at runtime via\n"
"`s.info(...)` (principle of *progressive disclosure*).\n\n"
"As with tools, only a subset of the operations is available in a given configuration:\n\n"
"* The facades `lsp` and `jb` are mutually exclusive, being tied to the respective language backend.\n"
"* Operations marked as *optional*, as well as all operations of facades marked as *optional*,\n"
" are disabled by default and must be enabled explicitly.\n\n"
"(repl-advantages)=\n\n"
"### Advantages\n\n"
"* **Composition**: The agent can combine several operations in a single call, using control flow,\n"
" filtering and aggregation. Multi-step retrievals that would otherwise require a series of\n"
" round-trips (find a symbol, inspect its members, find their references) become one call.\n"
"* **Context economy**: Only the result the agent actually needs enters the conversation.\n"
" Intermediate results remain in the Python runtime instead of consuming the context window\n"
" — which, for large result sets, is the difference between a summary and thousands of tokens.\n"
"* **Results as objects**: Operations return objects which can be processed programmatically\n"
" rather than plain text.\n"
"* **Progressive disclosure**: Instead of the schemas of dozens of tools, the agent is given a list of\n"
" the available operations, retrieving the details it requires (signatures, documentation, result\n"
" types) on demand. This eliminates the need for a client-specific tool search/dynamic tool discovery mechanism.\n"
"* **Reusability within a session**: Variables and helper functions defined by the agent persist\n"
" across calls, allowing intermediate results to be revisited and recurring logic to be applied\n"
" repeatedly.\n"
"* **Dynamic adaptation**: The set of available operations can change at runtime.\n"
" For MCP tools, changes to the toolset are not widely supported by clients and are therefore not\n"
" applied; within the REPL, the operations can follow the current situation.\n"
" This is what allows the language backend to be switched during a session, e.g. when a project\n"
" whose configuration demands a different backend is activated..\n\n"
"### List of Facades\n\n"
)
api_scope = ApiScope()
agent = SerenaAgent(serena_config=SerenaConfig().with_headless_mode_overrides())
facades = []
for backend in BuiltinLanguageBackend:
facades.extend(backend.get_instance().create_facades(agent, api_scope))
facades.extend(agent.create_default_facade_list(api_scope))
text = TextBuilder()
for facade in facades:
facade_info = " *(optional)*" if facade.is_optional() else ""
text.with_line(f"* **{facade.name}**{facade_info}: {facade.description}")
for method in facade.get_methods():
method_info = ""
if method.info.optional:
method_info += " *(optional)*"
if method.info.beta:
method_info += " [BETA]"
summary = method.get_summary().replace("`", "")
text.with_line(f"* `{method.qualified_name}`{method_info}: {summary}", indent=2)
f.write(text.build())
tools_section()
facades_section()
def autogen_about_intro_features(): def autogen_about_intro_features():
+15 -8
View File
@@ -4,7 +4,7 @@ requires = ["hatchling"]
[project] [project]
name = "serena-agent" name = "serena-agent"
version = "1.7.1.dev0" version = "2.0.0.dev0"
description = "A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent" description = "A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent"
authors = [{ name = "Oraios AI", email = "[email protected]" }] authors = [{ name = "Oraios AI", email = "[email protected]" }]
readme = "README.md" readme = "README.md"
@@ -21,7 +21,8 @@ dependencies = [
"requests==2.33.0", "requests==2.33.0",
"overrides==7.7.0", "overrides==7.7.0",
"python-dotenv==1.2.2", "python-dotenv==1.2.2",
"mcp==1.28.1", "mcp==2.2.0",
"click==8.3.1",
"flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert) "flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert)
"sensai-utils==1.5.0", "sensai-utils==1.5.0",
"pydantic==2.12.5", "pydantic==2.12.5",
@@ -107,17 +108,15 @@ packages = ["src/serena", "src/interprompt", "src/solidlsp"]
max-line-length = 1000 max-line-length = 1000
[tool.ty.environment] [tool.ty.environment]
python-version = "3.11" python-version = "3.11" # We configure the oldest Python version supported by Serena
# Analyze for all platforms rather than defaulting to the OS ty happens to run on. This keeps the # Analyze for all platforms rather than defaulting to the OS ty happens to run on. This keeps the
# check deterministic across the CI matrix (Linux/Windows/macOS) and lets platform-conditional stdlib # check deterministic across the CI matrix (Linux/Windows/macOS) and lets platform-conditional stdlib
# members (e.g. subprocess.CREATE_NO_WINDOW, ctypes.windll, pwd) resolve without per-OS type-ignores. # members (e.g. subprocess.CREATE_NO_WINDOW, ctypes.windll, pwd) resolve without per-OS type-ignores.
python-platform = "all" python-platform = "all"
[tool.ty.rules] [tool.ty.rules]
# Mirror mypy's ignore_missing_imports=true: optional extras (e.g. agno, google-genai) and # Unresolvable imports do fail the type check (catching broken first-party imports); the exceptions for
# platform-specific modules (e.g. AppKit on macOS) are not installed in the default dev environment, # optional extras and platform-specific modules are handled per file/line below.
# so we do not want unresolvable imports to fail the type check.
unresolved-import = "ignore"
possibly-missing-submodule = "ignore" possibly-missing-submodule = "ignore"
[tool.ty.src] [tool.ty.src]
@@ -127,6 +126,15 @@ possibly-missing-submodule = "ignore"
# test/resources is instead excluded from the `ty check test` CLI task via its --exclude flag. # test/resources is instead excluded from the `ty check test` CLI task via its --exclude flag.
exclude = ["build/", "docs/"] exclude = ["build/", "docs/"]
[[tool.ty.overrides]]
# Modules whose imports cannot be resolved in the default dev environment: the agno integration depends
# on the optional extra `agno`, and the pywebview integration imports macOS-only modules (AppKit, PyObjCTools)
# in several places. Elsewhere, individual platform-specific imports are suppressed inline.
include = ["src/serena/agno.py", "src/serena/util/pywebview.py"]
[tool.ty.overrides.rules]
unresolved-import = "ignore"
[[tool.ty.overrides]] [[tool.ty.overrides]]
# Test code is heavily dynamic (pytest fixtures, MagicMock, intentionally loose Optionals). ty models # Test code is heavily dynamic (pytest fixtures, MagicMock, intentionally loose Optionals). ty models
# pytest's fail/skip helpers and MagicMock far more strictly than mypy did (mypy inferred `Any` for # pytest's fail/skip helpers and MagicMock far more strictly than mypy did (mypy inferred `Any` for
@@ -145,7 +153,6 @@ no-matching-overload = "ignore"
not-subscriptable = "ignore" not-subscriptable = "ignore"
parameter-already-assigned = "ignore" parameter-already-assigned = "ignore"
too-many-positional-arguments = "ignore" too-many-positional-arguments = "ignore"
unresolved-attribute = "ignore"
unsupported-operator = "ignore" unsupported-operator = "ignore"
[tool.poe.env] [tool.poe.env]
+97 -83
View File
@@ -17,98 +17,110 @@ from serena.util.git import get_git_status
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
VersionPart = Literal["major", "minor", "patch"] VersionPart = Literal["major", "minor", "patch"]
#: a version part to bump or, in the case of "current", the version already reserved by the current .dev version
VersionTarget = Literal["major", "minor", "patch", "current"]
_VERSION_PATTERN = re.compile(r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\.\w+)?$") _VERSION_PATTERN = re.compile(r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\.\w+)?$")
_INIT_VERSION_PATTERN = re.compile(r'^(?P<before>__version__\s*=\s*")(?P<version>\d+\.\d+\.\d+(?:\.\w+)?)(?P<after>"\s*)$', re.MULTILINE) _INIT_VERSION_PATTERN = re.compile(r'^(?P<before>__version__\s*=\s*")(?P<version>\d+\.\d+\.\d+(?:\.\w+)?)(?P<after>"\s*)$', re.MULTILINE)
_PYPROJECT_VERSION_PATTERN = re.compile( _PYPROJECT_VERSION_PATTERN = re.compile(
r'(?m)^(?P<before>\[project\]\n(?:.*\n)*?^version\s*=\s*")(?P<version>\d+\.\d+\.\d+(?:\.\w+)?)(?P<after>"\s*)$' r'(?m)^(?P<before>\[project\]\n(?:.*\n)*?^version\s*=\s*")(?P<version>\d+\.\d+\.\d+(?:\.\w+)?)(?P<after>"\s*)$'
) )
_VERSION_SUFFIX_PATTERN = re.compile(r"^\d+\.\d+\.\d+\.(?P<suffix>\w+)$")
_UNRELEASED_HEADER = "# Unreleased (main)\n" _UNRELEASED_HEADER = "# Unreleased (main)\n"
@click.command() _version_target_argument = click.argument("version_target", type=click.Choice(["current", "major", "minor", "patch"]))
@click.option("--major", "major", is_flag=True, help="Bump the major version and reset minor and patch to 0.") _version_part_argument = click.argument("version_part", type=click.Choice(["major", "minor", "patch"]))
@click.option("--minor", "minor", is_flag=True, help="Bump the minor version and reset patch to 0.") _dry_run_option = click.option("--dry-run", is_flag=True, help="Show what would change without writing any files.")
@click.option("--patch", "patch", is_flag=True, help="Bump the patch version.")
@click.option("--version", "-v", "target_version", metavar="X.Y.Z", help="Set an explicit version instead of bumping.")
@click.option("--dry-run", is_flag=True, help="Show what would change without writing any files.")
def bump_version(major: bool, minor: bool, patch: bool, target_version: str | None, dry_run: bool) -> None:
git_status = get_git_status()
if not git_status.is_clean:
raise click.ClickException("Working directory is not clean. Please commit or stash your changes first.")
log.info("bump_version called: major=%s, minor=%s, patch=%s, target_version=%s", major, minor, patch, target_version)
# determine part to bump @click.group()
version_part = resolve_version_selection(major=major, minor=minor, patch=patch, target_version=target_version) def cli() -> None:
log.info("Resolved version_part=%s", version_part) """Manages the Serena version."""
@cli.command()
@_version_target_argument
@_dry_run_option
def release(version_target: VersionTarget, dry_run: bool) -> None:
"""Bumps the version for a release and starts the next dev iteration.
Bumps the version, updates the changelog, commits and tags the release, and then commits
the subsequent .dev0 version.
VERSION_TARGET is either "current", releasing the version already reserved by the current .dev version
(the usual case), or the part of the version to bump beyond it (major, minor or patch).
"""
require_clean_working_directory()
log.info("release called: version_target=%s", version_target)
# bump it (never incrementing patch because it was already updated with the last .dev version)
repo_root = find_repo_root() repo_root = find_repo_root()
log.info("Repo root: %s", repo_root) log.info("Repo root: %s", repo_root)
new_version = bump_repo_version(
repo_root, version_part=version_part, target_version=target_version, dry_run=dry_run, increment_patch=False
)
log.info("New version: %s", new_version)
# commit and tag for new version # bump to the release version
new_version = bump_repo_version(repo_root, version_target=version_target, dry_run=dry_run)
log.info("New version: %s", new_version)
if dry_run: if dry_run:
click.echo(f"Dry run complete. Version would be bumped to {new_version}") click.echo(f"Dry run complete. Version would be bumped to {new_version}")
return return
else:
os.system("uv lock")
click.echo(f"Bumped version to {new_version}")
os.system("git add -u")
os.system(f'git commit -m "Release v{new_version}"')
os.system(f"git tag v{new_version}")
# bump patch and add suffix for next dev iteration # commit and tag the release version
new_snapshot_version = bump_repo_version( commit_version_change(new_version, message=f"Release v{new_version}")
repo_root, os.system(f"git tag v{new_version}")
version_part="patch",
target_version=None, # bump patch and add the suffix for the next dev iteration
dry_run=dry_run, new_snapshot_version = bump_repo_version(repo_root, version_target="patch", dry_run=dry_run, target_version_suffix=".dev0")
target_version_suffix=".dev0",
increment_patch=True,
)
log.info("New snapshot version: %s", new_snapshot_version) log.info("New snapshot version: %s", new_snapshot_version)
commit_version_change(new_snapshot_version, message=f"Set version to v{new_snapshot_version}")
# commit the new snapshot version
@cli.command()
@_version_part_argument
@_dry_run_option
def dev(version_part: VersionPart, dry_run: bool) -> None:
"""Bumps the development version without creating a release.
Sets the version to a new .dev0 version and commits it; no tag is created and the changelog
is not modified.
VERSION_PART is the part of the version to bump (major, minor or patch).
"""
require_clean_working_directory()
log.info("dev called: version_part=%s", version_part)
repo_root = find_repo_root()
log.info("Repo root: %s", repo_root)
new_version = bump_repo_version(repo_root, version_target=version_part, dry_run=dry_run, target_version_suffix=".dev0")
log.info("New version: %s", new_version)
if dry_run:
click.echo(f"Dry run complete. Version would be bumped to {new_version}")
return
commit_version_change(new_version, message=f"Set version to v{new_version}")
def require_clean_working_directory() -> None:
if not get_git_status().is_clean:
raise click.ClickException("Working directory is not clean. Please commit or stash your changes first.")
def commit_version_change(new_version: str, *, message: str) -> None:
os.system("uv lock") os.system("uv lock")
click.echo(f"Bumped version to {new_snapshot_version}") click.echo(f"Bumped version to {new_version}")
os.system("git add -u") os.system("git add -u")
os.system(f'git commit -m "Set version to v{new_snapshot_version}"') os.system(f'git commit -m "{message}"')
def find_repo_root() -> Path: def find_repo_root() -> Path:
return Path(REPO_ROOT) return Path(REPO_ROOT)
def resolve_version_selection(*, major: bool, minor: bool, patch: bool, target_version: str | None) -> VersionPart | None:
bump_flags_selected = sum([major, minor, patch])
if target_version is not None and bump_flags_selected > 0:
raise click.ClickException("Use either --version or one of --major/--minor/--patch, not both.")
if bump_flags_selected > 1:
raise click.ClickException("Use only one of --major, --minor, or --patch.")
if target_version is not None:
validate_version_string(target_version)
return None
if major:
return "major"
if minor:
return "minor"
if patch:
return "patch"
raise click.ClickException("No version bump selected. Use --major, --minor, --patch or --version.")
def bump_repo_version( def bump_repo_version(
repo_root: Path, repo_root: Path,
*, *,
version_part: VersionPart | None, version_target: VersionTarget,
target_version: str | None,
dry_run: bool = False, dry_run: bool = False,
target_version_suffix: str | None = None, target_version_suffix: str | None = None,
increment_patch: bool = True,
) -> str: ) -> str:
pyproject_path = repo_root / "pyproject.toml" pyproject_path = repo_root / "pyproject.toml"
init_path = repo_root / "src" / "serena" / "__init__.py" init_path = repo_root / "src" / "serena" / "__init__.py"
@@ -130,12 +142,12 @@ def bump_repo_version(
f"Version mismatch between pyproject.toml and src/serena/__init__.py: {current_version} != {init_version}" f"Version mismatch between pyproject.toml and src/serena/__init__.py: {current_version} != {init_version}"
) )
if target_version is not None: if version_target == "current" and _VERSION_SUFFIX_PATTERN.search(current_version) is None:
new_version = validate_version_string(target_version) raise click.ClickException(
else: f"The current version {current_version} is not a development version, so there is no reserved version to release. "
if version_part is None: f"Use major, minor or patch to bump the version instead."
raise click.ClickException("No version target specified.") )
new_version = increment_version(current_version, version_part, increment_patch=increment_patch) new_version = increment_version(current_version, version_target)
if target_version_suffix is not None: if target_version_suffix is not None:
new_version += target_version_suffix new_version += target_version_suffix
log.info("New version will be: %s", new_version) log.info("New version will be: %s", new_version)
@@ -199,7 +211,14 @@ def replace_version(text: str, pattern: re.Pattern[str], new_version: str, file_
return f"{text[: match.start('version')]}{new_version}{text[match.end('version') :]}" return f"{text[: match.start('version')]}{new_version}{text[match.end('version') :]}"
def increment_version(version: str, version_part: VersionPart, increment_patch: bool) -> str: def increment_version(version: str, version_target: VersionTarget) -> str:
"""
Computes the new version, dropping any development suffix of the given version.
:param version: the current version
:param version_target: the part of the version to bump or "current" to keep the version as is
:return: the new version
"""
match = _VERSION_PATTERN.fullmatch(version) match = _VERSION_PATTERN.fullmatch(version)
if match is None: if match is None:
raise click.ClickException(f"Unsupported version format: {version}") raise click.ClickException(f"Unsupported version format: {version}")
@@ -208,22 +227,17 @@ def increment_version(version: str, version_part: VersionPart, increment_patch:
minor = int(match.group("minor")) minor = int(match.group("minor"))
patch = int(match.group("patch")) patch = int(match.group("patch"))
if version_part == "major": match version_target:
return f"{major + 1}.0.0" case "major":
if version_part == "minor": return f"{major + 1}.0.0"
return f"{major}.{minor + 1}.0" case "minor":
elif version_part == "patch": return f"{major}.{minor + 1}.0"
if increment_patch: case "patch":
patch += 1 return f"{major}.{minor}.{patch + 1}"
return f"{major}.{minor}.{patch}" case "current":
else: return f"{major}.{minor}.{patch}"
raise ValueError(version_part) case _:
raise ValueError(version_target)
def validate_version_string(version: str) -> str:
if _VERSION_PATTERN.fullmatch(version) is None:
raise click.ClickException(f"Unsupported version format: {version}")
return version
def update_changelog(changelog_text: str, new_version: str) -> str: def update_changelog(changelog_text: str, new_version: str) -> str:
@@ -278,4 +292,4 @@ def split_unreleased_body(unreleased_body: str) -> tuple[str, str]:
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s") logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s")
log.info("Script starting") log.info("Script starting")
bump_version() cli()
+3 -2
View File
@@ -14,8 +14,9 @@ from pathlib import Path
from pprint import pprint from pprint import pprint
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.project import Project from serena.project import Project
from serena.tools import ( from serena.tools import (
CreateTextFileTool, CreateTextFileTool,
@@ -35,7 +36,7 @@ def make_agent() -> SerenaAgent:
"""Create an LSP-backed Serena agent for the Serena repository.""" """Create an LSP-backed Serena agent for the Serena repository."""
serena_config = SerenaConfig.from_config_file() serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
project = Project( project = Project(
project_root=str(REPO_PATH), project_root=str(REPO_PATH),
+3 -2
View File
@@ -9,8 +9,9 @@ from pathlib import Path
from pprint import pprint from pprint import pprint
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.project import Project from serena.project import Project
from serena.tools import FindDeclarationTool from serena.tools import FindDeclarationTool
from solidlsp.ls_config import LanguageServerId from solidlsp.ls_config import LanguageServerId
@@ -24,7 +25,7 @@ def make_agent(project_root: Path, language: LanguageServerId, project_name: str
"""Create an LSP-backed Serena agent for a single explicit project.""" """Create an LSP-backed Serena agent for a single explicit project."""
serena_config = SerenaConfig.from_config_file() serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
project = Project( project = Project(
project_root=str(project_root), project_root=str(project_root),
+3 -2
View File
@@ -8,8 +8,9 @@ from pathlib import Path
from pprint import pprint from pprint import pprint
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.project import Project from serena.project import Project
from serena.tools import FindImplementationsTool from serena.tools import FindImplementationsTool
from solidlsp.ls_config import LanguageServerId from solidlsp.ls_config import LanguageServerId
@@ -22,7 +23,7 @@ def make_agent(project_root: Path, language: LanguageServerId, project_name: str
"""Create an LSP-backed Serena agent for a single explicit project.""" """Create an LSP-backed Serena agent for a single explicit project."""
serena_config = SerenaConfig.from_config_file() serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
project = Project( project = Project(
project_root=str(project_root), project_root=str(project_root),
+6 -5
View File
@@ -11,8 +11,9 @@ import json
from pprint import pprint from pprint import pprint
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, SerenaConfig from serena.config.serena_config import SerenaConfig
from serena.constants import REPO_ROOT from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.tools import ( from serena.tools import (
FindReferencingSymbolsTool, FindReferencingSymbolsTool,
FindSymbolTool, FindSymbolTool,
@@ -165,16 +166,16 @@ def run_jb_tools(agent: SerenaAgent) -> None:
) )
def make_agent(backend: LanguageBackend) -> SerenaAgent: def make_agent(backend: BuiltinLanguageBackend) -> SerenaAgent:
config = SerenaConfig.from_config_file() config = SerenaConfig.from_config_file()
config.web_dashboard = False config.web_dashboard = False
config.language_backend = backend config.set_builtin_language_backend(backend)
return SerenaAgent(project=REPO_ROOT, serena_config=config) return SerenaAgent(project=REPO_ROOT, serena_config=config)
if __name__ == "__main__": if __name__ == "__main__":
# LSP backend # LSP backend
lsp_agent = make_agent(LanguageBackend.LSP) lsp_agent = make_agent(BuiltinLanguageBackend.LSP)
try: try:
run_lsp_tools(lsp_agent) run_lsp_tools(lsp_agent)
run_backend_independent_tools(lsp_agent) run_backend_independent_tools(lsp_agent)
@@ -183,7 +184,7 @@ if __name__ == "__main__":
# JetBrains backend (requires a running IDE) # JetBrains backend (requires a running IDE)
try: try:
jb_agent = make_agent(LanguageBackend.JETBRAINS) jb_agent = make_agent(BuiltinLanguageBackend.JETBRAINS)
try: try:
run_jb_tools(jb_agent) run_jb_tools(jb_agent)
finally: finally:
+3 -2
View File
@@ -9,8 +9,9 @@ from pathlib import Path
from pprint import pprint from pprint import pprint
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, SerenaConfig from serena.config.serena_config import SerenaConfig
from serena.constants import REPO_ROOT from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.tools import ( from serena.tools import (
FindFileTool, FindFileTool,
FindReferencingSymbolsTool, FindReferencingSymbolsTool,
@@ -26,7 +27,7 @@ from serena.tools import (
if __name__ == "__main__": if __name__ == "__main__":
serena_config = SerenaConfig.from_config_file() serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
# project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy" # project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy"
project = Path(REPO_ROOT) project = Path(REPO_ROOT)
agent = SerenaAgent(project=str(project), serena_config=serena_config) agent = SerenaAgent(project=str(project), serena_config=serena_config)
+1 -1
View File
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
__version__ = "1.7.1.dev0" __version__ = "2.0.0.dev0"
import logging import logging
+292 -105
View File
@@ -17,7 +17,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from logging import Logger from logging import Logger
from typing import TYPE_CHECKING, Optional, TypeVar from typing import TYPE_CHECKING, Optional, TypeVar, cast
import requests import requests
import webview import webview
@@ -31,10 +31,11 @@ from serena import serena_version
from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import ( from serena.config.serena_config import (
LanguageBackend, AgentInterface,
ModeSelectionDefinition, ModeSelectionDefinition,
ModeSelectionDefinitionWithAddedModes, ModeSelectionDefinitionWithAddedModes,
ModeSelectionDefinitionWithBaseModes, ModeSelectionDefinitionWithBaseModes,
NamedApiInclusionDefinition,
NamedToolInclusionDefinition, NamedToolInclusionDefinition,
RegisteredProject, RegisteredProject,
SerenaConfig, SerenaConfig,
@@ -42,19 +43,30 @@ from serena.config.serena_config import (
ToolInclusionDefinition, ToolInclusionDefinition,
) )
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser
from serena.jetbrains import launch_coordinator as jetbrains_launch_coordinator from serena.language_backend import BuiltinLanguageBackend, LanguageBackend
from serena.ls_manager import LanguageServerManager from serena.ls_manager import LanguageServerManager
from serena.memories.memory_manager import MemoryManager from serena.memories.memory_manager import MemoryManager
from serena.project import Project from serena.project import Project
from serena.prompt_factory import SerenaPromptFactory from serena.prompt_factory import SerenaPromptFactory
from serena.repl.api.cfg_api import ConfigApi
from serena.repl.api.edit_api import EditApi
from serena.repl.api.ext_api import ExternalProjectsApi
from serena.repl.api.fs_api import FsApi
from serena.repl.api.mem_api import MemoryApi
from serena.repl.api.shell_api import ShellApi
from serena.repl.facade import ApiScope, Facade
from serena.repl.repl import SerenaRepl
from serena.session import SerenaSession, SessionRegistry
from serena.task_executor import TaskExecutor from serena.task_executor import TaskExecutor
from serena.tools import ( from serena.tools import (
ActivateProjectTool, ActivateProjectTool,
GetCurrentConfigTool, GetCurrentConfigTool,
InitialInstructionsTool,
OnboardingTool, OnboardingTool,
OpenDashboardTool, OpenDashboardTool,
ReadMemoryTool, ReadMemoryTool,
ReplaceContentTool, ReplaceContentTool,
SerenaReplTool,
Tool, Tool,
ToolMarker, ToolMarker,
ToolRegistry, ToolRegistry,
@@ -427,7 +439,7 @@ class DashboardManager:
fallback_mode = self.Mode.from_platform() fallback_mode = self.Mode.from_platform()
log.warning( log.warning(
f"Dashboard interface mode '{mode.value}' is not supported on the current platform; " f"Dashboard interface mode '{mode.value}' is not supported on the current platform; "
"falling back to '{fallback_mode.value}'." f"falling back to '{fallback_mode.value}'."
) )
mode = fallback_mode mode = fallback_mode
@@ -567,9 +579,12 @@ class SerenaAgent:
self._gui_log_viewer: Optional["GuiLogViewer"] = None self._gui_log_viewer: Optional["GuiLogViewer"] = None
self._dashboard_manager: DashboardManager | None = None self._dashboard_manager: DashboardManager | None = None
self._project_prompt_status = ProjectPromptProvisionStatus() self._project_prompt_status = ProjectPromptProvisionStatus()
self._session_registry = SessionRegistry()
self._session_mode_selection_definition = modes self._session_mode_selection_definition = modes
self.version = serena_version() self.version = serena_version()
self._config_changed_callbacks: list[Callable[[], None]] = [] self._config_changed_callbacks: list[Callable[[], None]] = []
self._repl: SerenaRepl | None = None
self._prompt_params: SerenaAgent.PromptParams | None = None
# obtain serena configuration using the decoupled factory function # obtain serena configuration using the decoupled factory function
self.serena_config = serena_config or SerenaConfig.from_config_file() self.serena_config = serena_config or SerenaConfig.from_config_file()
@@ -645,14 +660,19 @@ class SerenaAgent:
# determine the effective language backend for this session. # determine the effective language backend for this session.
# If a startup project is provided and has a per-project override, use it; otherwise use the global config. # If a startup project is provided and has a per-project override, use it; otherwise use the global config.
# Since we don't want to change the toolset after startup, the language backend cannot be changed within a running Serena session # With the tool interface, the backend cannot change within a session (the toolset depends on it and is fixed);
# with the REPL interface, it may change upon project activation (see _activate_project).
self._language_backend = self.serena_config.determine_language_backend( self._language_backend = self.serena_config.determine_language_backend(
project_config=registered_project_to_activate.project_config if registered_project_to_activate is not None else None, project_config=registered_project_to_activate.project_config if registered_project_to_activate is not None else None,
log_choice=True, log_choice=True,
) )
# create the tool names mapping for prompts # determine the effective agent interface for this session (project configuration > global configuration).
self._prompt_tool_names_mapping = self._create_prompt_tool_names_mapping(self._language_backend) # Like the language backend, it is fixed for the session, since the set of exposed tools cannot change after startup.
self._agent_interface = self.serena_config.determine_agent_interface(
project_config=registered_project_to_activate.project_config if registered_project_to_activate is not None else None,
log_choice=True,
)
# create executor for starting the language server and running tools in another thread # create executor for starting the language server and running tools in another thread
# This executor is used to achieve linear task execution # This executor is used to achieve linear task execution
@@ -676,8 +696,14 @@ class SerenaAgent:
self._project_activation_error = str(e) self._project_activation_error = str(e)
self._update_active_modes() self._update_active_modes()
# determine whether we are operating in a single-project session, i.e. the project that was activated at startup
# (if any) is the only project that will be worked with throughout the session (no project switching)
self._is_single_project = self._context.single_project and self._active_project is not None
# determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see),
self._base_toolset = self._create_base_toolset(self.serena_config, self._context, self._active_modes, self._active_project) self._base_toolset = self._create_base_toolset(
self.serena_config, self._context, self._active_modes, self._active_project, self._agent_interface, self._is_single_project
)
self._exposed_tools = self._base_toolset.to_available_tools(self._all_tools) self._exposed_tools = self._base_toolset.to_available_tools(self._all_tools)
log.info(f"Number of exposed tools: {len(self._exposed_tools)}. Exposed tools: {self._exposed_tools.tool_names}") log.info(f"Number of exposed tools: {len(self._exposed_tools)}. Exposed tools: {self._exposed_tools.tool_names}")
@@ -729,7 +755,7 @@ class SerenaAgent:
"os": platform.system(), "os": platform.system(),
"dashboard": int(self.serena_config.web_dashboard), "dashboard": int(self.serena_config.web_dashboard),
"version": self.version, "version": self.version,
"backend": self._language_backend.value, "backend": self._language_backend.get_key(),
"context": self._context.name, "context": self._context.name,
} }
try: try:
@@ -737,6 +763,15 @@ class SerenaAgent:
except Exception as e: except Exception as e:
log.debug(f"Failed to send usage info: {e}") log.debug(f"Failed to send usage info: {e}")
@staticmethod
def _is_dashboard_openable(serena_config: SerenaConfig) -> bool:
"""
:param serena_config: the configuration
:return: whether the web dashboard is available and opening it is a meaningful operation
(i.e. it is enabled and not opened automatically)
"""
return serena_config.web_dashboard and not serena_config.web_dashboard_open_on_launch and not serena_config.gui_log_window
@classmethod @classmethod
def _create_base_toolset( def _create_base_toolset(
cls, cls,
@@ -744,10 +779,12 @@ class SerenaAgent:
context: SerenaAgentContext, context: SerenaAgentContext,
modes: ActiveModes, modes: ActiveModes,
project: Project | None, project: Project | None,
agent_interface: AgentInterface,
is_single_project: bool,
) -> ToolSet: ) -> ToolSet:
""" """
Determines the base toolset defining the set of exposed tools (which e.g. the MCP shall see). Determines the base toolset defining the set of exposed tools (which e.g. the MCP shall see).
It depends on ... In REPL mode, the toolset is fixed. Otherwise, it depends on ...
* dashboard availability/opening on launch * dashboard availability/opening on launch
* Serena config * Serena config
* the context (which is fixed for the session) * the context (which is fixed for the session)
@@ -755,9 +792,16 @@ class SerenaAgent:
* the optional tools enabled by initial dynamic modes * the optional tools enabled by initial dynamic modes
* single-project mode reductions (if applicable) * single-project mode reductions (if applicable)
""" """
# when in REPL mode, the toolset is fixed and does not depend on the configuration, context, modes or project
if agent_interface.is_repl():
tool_classes: list[type[Tool]] = [SerenaReplTool, InitialInstructionsTool]
if not is_single_project:
tool_classes.append(ActivateProjectTool)
return ToolSet({tool_class.get_name_from_cls() for tool_class in tool_classes})
# determine whether to include the OpenDashboardTool based on the Serena configuration # determine whether to include the OpenDashboardTool based on the Serena configuration
tool_inclusion_definitions: list[ToolInclusionDefinition] = [] tool_inclusion_definitions: list[ToolInclusionDefinition] = []
if serena_config.web_dashboard and not serena_config.web_dashboard_open_on_launch and not serena_config.gui_log_window: if cls._is_dashboard_openable(serena_config):
tool_inclusion_definitions.append( tool_inclusion_definitions.append(
NamedToolInclusionDefinition(name="OpenDashboard", included_optional_tools=[OpenDashboardTool.get_name_from_cls()]) NamedToolInclusionDefinition(name="OpenDashboard", included_optional_tools=[OpenDashboardTool.get_name_from_cls()])
) )
@@ -766,10 +810,6 @@ class SerenaAgent:
tool_inclusion_definitions.append(serena_config) tool_inclusion_definitions.append(serena_config)
tool_inclusion_definitions.append(context) tool_inclusion_definitions.append(context)
# determine whether we are operating in a single-project context
# (i.e. the project that is activated at startup is the only project that will be worked with throughout the session)
is_single_project = context.single_project and project is not None
# consider modes # consider modes
# * base modes: These cannot be changed, so they are fully applied # * base modes: These cannot be changed, so they are fully applied
for base_mode in modes.get_base_modes(include_background_base_modes=True): for base_mode in modes.get_base_modes(include_background_base_modes=True):
@@ -821,6 +861,17 @@ class SerenaAgent:
def get_language_backend(self) -> LanguageBackend: def get_language_backend(self) -> LanguageBackend:
return self._language_backend return self._language_backend
def is_single_project(self) -> bool:
"""
:return: whether this is a single-project session, i.e. the project activated at startup is the only project
that will be worked with throughout the session (no project switching); requires a single-project context
and a project at startup
"""
return self._is_single_project
def get_agent_interface(self) -> AgentInterface:
return self._agent_interface
def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]: def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]:
""" """
Gets the list of tasks currently running or queued for execution. Gets the list of tasks currently running or queued for execution.
@@ -939,27 +990,97 @@ class SerenaAgent:
""" """
return self._active_modes return self._active_modes
@staticmethod @dataclass
def _create_prompt_tool_names_mapping(language_backend: LanguageBackend) -> dict[str, str]: class PromptParams:
""" """
Creates a mapping from tool names to new tool names, which take into consideration Holds parameters for prompt rendering
* legacy tool names, where the name was changed and
* LSP tools which are functionally replaced by other tools due to the active language backend
(e.g. "find_symbol" being replaced by "jet_brains_find_symbol" in JetBrains mode).
The mapping is intended to be used for the generation of prompts, such that prompts can
refer to tool names as `{{ tool_names["find_symbol"] }}`, and the mapping will ensure that
the correct tool name is used in the prompt based on the active language backend.
:return: the mapping from tool names to new tool names
""" """
result = dict(ToolSet.LEGACY_TOOL_NAME_MAPPING)
class_replacements = language_backend.get_lsp_tool_class_replacements() available_tools: set[str]
for tool_class in ToolRegistry().get_all_tool_classes(): """
new_tool_class: type[Tool] = class_replacements.get(tool_class, tool_class) available tool names or, in REPL mode, the names of the raw facade methods (without facade name prefix) and
result[tool_class.get_name_from_cls()] = new_tool_class.get_name_from_cls() the names of the corresponding tools
return result """
available_markers: set[str]
"""
names of the ToolMarkers (class names) that the available tools inherit from
"""
tool_names_mapping: dict[str, str]
"""
mapping from standard tool names to currently used and replacement tool/API method names.
In particular, this maps
* legacy tool names to current tool names
* LSP tool names to their backend- and interface-specific counterparts
(e.g. "find_symbol" to "jet_brains_find_symbol" in JetBrains mode, "find_symbol" to the corresponding API method name
when using the REPL interface).
"""
def get_function_name(self, tool_class: type[Tool]) -> str:
"""
:param tool_class: the tool for which to get the function name
:return: the function name to use for this tool in prompts, which may be different from the tool's standard name
(e.g. when using a different language backend or when using the REPL interface)
"""
tool_name = tool_class.get_name_from_cls()
return self.tool_names_mapping.get(tool_name, tool_name)
def _get_prompt_params(self) -> PromptParams:
"""
:return: parameters for prompt rendering depending on the current agent interface, language backend and active tools/methods
"""
if self._prompt_params is not None:
return self._prompt_params
if self._agent_interface == AgentInterface.TOOLS:
# available tool names are simply the exposed tools
available_tool_names = set(self._exposed_tools.tool_names)
available_tool_marker_names = set(self._exposed_tools.tool_marker_names)
tool_name_mapping = dict(ToolSet.LEGACY_TOOL_NAME_MAPPING)
class_replacements = self._language_backend.get_lsp_tool_class_replacements()
for tool_class in ToolRegistry().get_all_tool_classes():
new_tool_class: type[Tool] = class_replacements.get(tool_class, tool_class)
tool_name_mapping[tool_class.get_name_from_cls()] = new_tool_class.get_name_from_cls()
elif self._agent_interface == AgentInterface.REPL:
# available tool names include both the names of the facade methods and the names of the corresponding tools
repl = self.get_repl()
enabled_methods = repl.entrypoint.get_enabled_methods()
corresponding_tool_classes = [m.info.corresponding_tool for m in enabled_methods if m.info.corresponding_tool is not None]
available_tools = AvailableTools(
self._exposed_tools.tools + [self._all_tools[tool_class] for tool_class in corresponding_tool_classes]
)
available_tool_names = set(available_tools.tool_names).union({m.info.name for m in enabled_methods})
available_tool_marker_names = set(available_tools.tool_marker_names)
tool_class_replacements = self._language_backend.get_lsp_tool_class_replacements()
methods_by_tool_class = {m.info.corresponding_tool: m for m in enabled_methods if m.info.corresponding_tool is not None}
def get_name(tool_class: type[Tool]) -> str:
# if there is a corresponding method in the API, return its qualified name (as used in REPL code)
method = methods_by_tool_class.get(tool_class)
if method is not None:
return method.qualified_name
# if there is a corresponding method for the replacement class, return its qualified name
replacement_class = tool_class_replacements.get(tool_class)
if replacement_class is not None:
replacement_method = methods_by_tool_class.get(replacement_class)
if replacement_method is not None:
return replacement_method.qualified_name
# otherwise, keep the tool's name
return tool_class.get_name_from_cls()
tool_name_mapping = {}
for legacy_name, new_name in ToolSet.LEGACY_TOOL_NAME_MAPPING.items():
tool_name_mapping[legacy_name] = get_name(ToolRegistry().get_tool_class_by_name(new_name))
for tool_class in ToolRegistry().get_all_tool_classes():
tool_name_mapping[tool_class.get_name_from_cls()] = get_name(tool_class)
else:
raise ValueError()
return self.PromptParams(
available_tools=available_tool_names, available_markers=available_tool_marker_names, tool_names_mapping=tool_name_mapping
)
@staticmethod @staticmethod
def _format_prompt_tag(text: str, tag: str, tag_name_attr: str | None = None) -> str: def _format_prompt_tag(text: str, tag: str, tag_name_attr: str | None = None) -> str:
@@ -986,10 +1107,11 @@ class SerenaAgent:
return "" return ""
template = JinjaTemplate(prompt_template) template = JinjaTemplate(prompt_template)
prompt_params = self._get_prompt_params()
text = template.render( text = template.render(
available_tools=self._exposed_tools.tool_names, available_tools=prompt_params.available_tools,
available_markers=self._exposed_tools.tool_marker_names, available_markers=prompt_params.available_markers,
tool_names=self._prompt_tool_names_mapping, tool_names=prompt_params.tool_names_mapping,
embed_memory=embed_memory, embed_memory=embed_memory,
) )
@@ -1021,18 +1143,33 @@ class SerenaAgent:
else: else:
return self._create_global_memory_manager() return self._create_global_memory_manager()
def create_system_prompt(self, session_id: str = "global") -> str: def create_session(self) -> SerenaSession:
"""
:return: a new client session (with a random id)
"""
return self._session_registry.create_session()
def get_session(self, session_id: str) -> SerenaSession:
"""
:param session_id: the session id (as supplied by the LLM)
:return: the session, which is created if it is unknown
"""
return self._session_registry.get_session(session_id)
def create_system_prompt(self) -> str:
""" """
Returns the 'Serena Instructions Manual', i.e. Serena's system prompt. Returns the 'Serena Instructions Manual', i.e. Serena's system prompt.
The prompt also establishes a new Serena session (see `SerenaSession`), stating its id for use with tools
which require it (e.g. the REPL tool and project activation tool).
:param session_id: the client session ID for the case where this is run from a tool; "global" for the connection time case
:return: the prompt :return: the prompt
""" """
available_tools = self._active_tools # establish a Serena session
available_markers = available_tools.tool_marker_names serena_session = self.create_session()
session_id = serena_session.session_id
global_memories = self._create_global_memory_manager().list_global_memories() global_memories = self._create_global_memory_manager().list_global_memories()
global_memories_str = dict_string(global_memories.to_dict()) if len(global_memories) > 0 else "" global_memories_str = dict_string(global_memories.to_dict()) if len(global_memories) > 0 else ""
log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers)
# determine modes for which prompts must (still) be provided, excluding modes that were already provided in a # determine modes for which prompts must (still) be provided, excluding modes that were already provided in a
# previously provided project activation message (if any) # previously provided project activation message (if any)
@@ -1043,13 +1180,14 @@ class SerenaAgent:
relevant_modes.append(mode) relevant_modes.append(mode)
self._project_prompt_status.mark_mode_prompts_as_provided(session_id) self._project_prompt_status.mark_mode_prompts_as_provided(session_id)
prompt_params = self._get_prompt_params()
system_prompt = self.prompt_factory.create_system_prompt( system_prompt = self.prompt_factory.create_system_prompt(
context_system_prompt=self._render_prompt(self._context.prompt, tag="context"), context_system_prompt=self._render_prompt(self._context.prompt, tag="context"),
mode_system_prompts=[self._render_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) for mode in relevant_modes], mode_system_prompts=[self._render_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) for mode in relevant_modes],
available_tools=available_tools.tool_names, available_tools=prompt_params.available_tools,
available_markers=available_markers, available_markers=prompt_params.available_markers,
global_memories_list=global_memories_str, global_memories_list=global_memories_str,
tool_names=self._prompt_tool_names_mapping, tool_names=prompt_params.tool_names_mapping,
) )
# provide the project activation message if it hasn't yet been provided # provide the project activation message if it hasn't yet been provided
@@ -1058,6 +1196,12 @@ class SerenaAgent:
elif self._project_activation_error: elif self._project_activation_error:
system_prompt += f"\n\nNo project is active ({self._project_activation_error})." system_prompt += f"\n\nNo project is active ({self._project_activation_error})."
# inform about the session id
system_prompt += "\n\n" + self._format_prompt_tag(
f"Your Serena session id is `{session_id}`. Pass it as the `session_id` parameter to tools which require it.",
tag="session",
)
return self._format_prompt_tag(system_prompt, tag="serena") return self._format_prompt_tag(system_prompt, tag="serena")
def get_project_activation_message(self, session_id: str) -> str: def get_project_activation_message(self, session_id: str) -> str:
@@ -1068,6 +1212,8 @@ class SerenaAgent:
proj = self._active_project proj = self._active_project
assert proj is not None, "A project must be active before calling this." assert proj is not None, "A project must be active before calling this."
prompt_params = self._get_prompt_params()
# Note: The activation message is always returned in full, even if it was already provided in the current session, # Note: The activation message is always returned in full, even if it was already provided in the current session,
# because some clients (e.g. Claude Desktop) will use the same session across multiple chats. # because some clients (e.g. Claude Desktop) will use the same session across multiple chats.
# So while we don't want the activation message to be additionally included in the system prompt # So while we don't want the activation message to be additionally included in the system prompt
@@ -1081,22 +1227,20 @@ class SerenaAgent:
msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}.\n" msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}.\n"
else: else:
msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated.\n" msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated.\n"
if self._language_backend == LanguageBackend.LSP: msg += self._language_backend.get_project_activation_statement(proj)
language_servers_str = ", ".join([ls.get_key() for ls in proj.project_config.language_servers])
msg += f"Active language servers: {language_servers_str}.\n"
msg += f"File encoding: {proj.project_config.encoding}.\n" msg += f"File encoding: {proj.project_config.encoding}.\n"
# add list of memories (if memories are enabled) # add list of memories (if memories are enabled)
include_memories = self._active_tools.contains_tool_class(ReadMemoryTool) include_memories = self.is_tool_function_available(ReadMemoryTool)
if include_memories: if include_memories:
project_memories = proj.memory_manager.list_project_memories() project_memories = proj.memory_manager.list_project_memories()
if project_memories: if project_memories:
msg += ( msg += (
f"{json.dumps(project_memories.to_dict())}\n" f"{json.dumps(project_memories.to_dict())}\n"
+ f"Use the `{ReadMemoryTool.get_name_from_cls()}` tool to read these memories later if they are relevant to the task.\n" + f"Use `{prompt_params.get_function_name(ReadMemoryTool)}` to read these memories later if they are relevant to the task.\n"
) )
elif self._active_tools.contains_tool_class(OnboardingTool): elif self.is_tool_function_available(OnboardingTool):
msg += f"Onboarding has not been performed yet. Ask the user whether to perform onboarding via the `{OnboardingTool.get_name_from_cls()}` tool.\n" msg += f"Onboarding has not been performed yet. Ask the user whether to perform onboarding and if so, call `{prompt_params.get_function_name(OnboardingTool)}`.\n"
# add prompts for modes that were dynamically activated by the project # add prompts for modes that were dynamically activated by the project
modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation(session_id) modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation(session_id)
@@ -1105,10 +1249,15 @@ class SerenaAgent:
msg += self._render_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) + "\n" msg += self._render_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) + "\n"
self._project_prompt_status.mark_mode_prompts_as_provided(session_id) self._project_prompt_status.mark_mode_prompts_as_provided(session_id)
# add project-specific prompt # add the project's prompt (if any)
if proj.project_config.initial_prompt: if proj.project_config.initial_prompt:
msg += "\n" + self._render_prompt(proj.project_config.initial_prompt, tag="project-instructions") msg += "\n" + self._render_prompt(proj.project_config.initial_prompt, tag="project-instructions")
# when the REPL is active, add information on available facades if the agent is not in single-project mode
# (for single-project mode where the facades can't change, they are provided in the tool's description)
if self._active_tools.contains_tool_class(SerenaReplTool) and not self.is_single_project():
msg += f"\n\nAvailable facades for the `{SerenaReplTool.get_name_from_cls()}` tool:\n" + self.get_repl().entrypoint.overview()
self._project_prompt_status.mark_project_activation_message_as_provided(session_id) self._project_prompt_status.mark_project_activation_message_as_provided(session_id)
return msg return msg
@@ -1133,22 +1282,31 @@ class SerenaAgent:
def _update_active_tools(self) -> None: def _update_active_tools(self) -> None:
""" """
Updates the active tools based on the active modes and the active project. Updates the active tools (and the REPL, which depends on the same configuration) based on the active modes
and the active project. Must be called whenever the active modes or the active project change.
The base tool set already takes the Serena configuration and the context into account The base tool set already takes the Serena configuration and the context into account
(as well as many other aspects, such as JetBrains mode). (as well as many other aspects, such as JetBrains mode).
""" """
# apply modes if self._agent_interface.is_repl():
tool_set = self._base_toolset.apply(*self._active_modes.get_modes()) # the REPL toolset is fixed; tool inclusion/exclusion definitions do not apply
tool_set = self._base_toolset
else:
# apply modes
tool_set = self._base_toolset.apply(*self._active_modes.get_modes())
# apply active project configuration (if any) # apply active project configuration (if any)
if self._active_project is not None: if self._active_project is not None:
tool_set = tool_set.apply(self._active_project.project_config) tool_set = tool_set.apply(self._active_project.project_config)
if self._active_project.project_config.read_only: if self._active_project.project_config.read_only:
tool_set = tool_set.without_editing_tools() tool_set = tool_set.without_editing_tools()
self._active_tools = tool_set.to_available_tools(self._all_tools) self._active_tools = tool_set.to_available_tools(self._all_tools)
log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self._active_tools.tool_names)}") log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self._active_tools.tool_names)}")
# reset members that depend on the active tools, so that they are re-created on demand with the new active tools
self._repl = None
self._prompt_params = None
# check if a tool was activated that is not in the exposed tool set and issue a warning if so # check if a tool was activated that is not in the exposed tool set and issue a warning if so
active_tools_not_exposed = set(self._active_tools.tool_names) - set(self._exposed_tools.tool_names) active_tools_not_exposed = set(self._active_tools.tool_names) - set(self._exposed_tools.tool_names)
if active_tools_not_exposed: if active_tools_not_exposed:
@@ -1158,6 +1316,41 @@ class SerenaAgent:
"Consider adjusting your configuration to include these tools if you want to use them." "Consider adjusting your configuration to include these tools if you want to use them."
) )
def create_default_facade_list(self, api_scope: ApiScope) -> list[Facade]:
"""
:return: the default list of facades provided by Serena itself, not including any language backend-specific facades
"""
return [
Facade.from_api(ConfigApi(self), api_scope),
Facade.from_api(FsApi(self), api_scope),
Facade.from_api(EditApi(self), api_scope),
Facade.from_api(MemoryApi(self), api_scope),
Facade.from_api(ShellApi(self), api_scope),
Facade.from_api(ExternalProjectsApi(self), api_scope, is_optional=True),
]
def get_repl(self) -> SerenaRepl:
"""
:return: the REPL instance for this agent, creating it if necessary
"""
if self._repl is None:
# determine API scope
api_scope = ApiScope()
api_scope.process(self.serena_config)
api_scope.process(self._context)
for mode in self._active_modes.get_modes():
api_scope.process(mode)
if self._active_project:
api_scope.process(self._active_project.project_config)
if self._active_project.project_config.read_only:
api_scope.exclude_editing()
if not self._is_dashboard_openable(self.serena_config):
api_scope.process(NamedApiInclusionDefinition(name="Dashboard", excluded_apis=["cfg.open_dashboard"]))
facades = self.create_default_facade_list(api_scope) + self._language_backend.create_facades(self, api_scope)
self._repl = SerenaRepl(facades, api_scope)
return self._repl
def issue_task( def issue_task(
self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None
) -> TaskExecutor.Task[T]: ) -> TaskExecutor.Task[T]:
@@ -1211,7 +1404,7 @@ class SerenaAgent:
""" """
:return: whether this agent uses language server-based code analysis :return: whether this agent uses language server-based code analysis
""" """
return self._language_backend == LanguageBackend.LSP return self._language_backend == BuiltinLanguageBackend.LSP
def _activate_project(self, project: Project, update_active_modes: bool = True, update_active_tools: bool = True) -> bool: def _activate_project(self, project: Project, update_active_modes: bool = True, update_active_tools: bool = True) -> bool:
""" """
@@ -1225,15 +1418,22 @@ class SerenaAgent:
self._project_activation_error = None self._project_activation_error = None
# check if the project requires a different language backend than the one initialized at startup # handle the case where the project requires a different language backend than the current one.
# With the tool interface, the backend cannot change, since the set of exposed tools depends on it and is fixed
# for the session. With the REPL interface, the backend can be switched, as all backend-dependent state
# (background modes, REPL facades, prompt parameters, the project's language backend initialisation) is
# recomputed upon activation.
project_backend = project.project_config.language_backend project_backend = project.project_config.language_backend
if project_backend is not None and project_backend != self._language_backend: if project_backend is not None and project_backend != self._language_backend:
raise ValueError( if self._agent_interface.is_tools():
f"Cannot activate project '{project.project_name}': it requires the {project_backend.value} backend, " raise ValueError(
f"but this session was initialized with {self._language_backend.value}. " f"Cannot activate project '{project.project_name}': it requires the {project_backend} backend, "
f"Workarounds: (1) Use project activation at startup via the --project flag, " f"but this session was initialized with {self._language_backend}. "
f"(2) Configure one MCP server per backend in your client." f"Workarounds: (1) Use project activation at startup via the --project flag, "
) f"(2) Configure one MCP server per backend in your client, (3) use the REPL interface."
)
log.info(f"Switching language backend from {self._language_backend} to {project_backend} for project '{project.project_name}'")
self._language_backend = project_backend
# shut down the previously active project to release its language server processes # shut down the previously active project to release its language server processes
if self._active_project is not None: if self._active_project is not None:
@@ -1257,7 +1457,7 @@ class SerenaAgent:
def init_project_services() -> None: def init_project_services() -> None:
self._run_project_activation_command(project) self._run_project_activation_command(project)
self._init_active_project_language_backend() self._language_backend.init_active_project(self)
# initialise the project's language backend in the background # initialise the project's language backend in the background
self.issue_task(init_project_services) self.issue_task(init_project_services)
@@ -1313,28 +1513,6 @@ class SerenaAgent:
except Exception: except Exception:
log.exception(f"Unexpected error running activation_command for project '{project.project_name}'") log.exception(f"Unexpected error running activation_command for project '{project.project_name}'")
def _init_active_project_language_backend(self) -> None:
"""
Initialises the active project's language backend
"""
project = self._active_project
assert project is not None
# for LSP mode, start the language server manager
if self.get_language_backend().is_lsp():
with LogTime("Language server initialization", logger=log):
self.reset_language_server_manager()
# for JetBrains mode, search for plugin server and spawn IDE (if not found and launch command provided)
elif self.get_language_backend().is_jetbrains():
client = jetbrains_launch_coordinator.find_plugin_server(project)
if client is not None:
log.info("Found Serena JetBrains Plugin server: %s", client)
else:
log.info("Serena JetBrains Plugin server not found for project %s", project.project_name)
if self.serena_config.jetbrains_launch_command:
jetbrains_launch_coordinator.launch_and_wait_for_plugin_server(project, self.serena_config.jetbrains_launch_command)
def activate_project_from_path_or_name( def activate_project_from_path_or_name(
self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True
) -> bool: ) -> bool:
@@ -1367,19 +1545,27 @@ class SerenaAgent:
""" """
return self._active_tools.tool_names return self._active_tools.tool_names
def tool_is_active(self, tool_name: str) -> bool: def get_active_tools(self) -> AvailableTools:
""" """
:param tool_class: the name of the tool to check :return: the set of active tools
:return: True if the tool is active, False otherwise
""" """
return self._active_tools.contains_tool_name(tool_name) return self._active_tools
def tool_is_exposed(self, tool_name: str) -> bool: def is_tool_function_available(self, tool_class: type[Tool]) -> bool:
""" """
:param tool_name: the name of the tool to check Checks whether the functionality offered by a tool is available - either through the tool
:return: True if the tool is in the exposed tool set, False otherwise itself being enabled or through the corresponding function being exposed in the REPL.
:param tool_class: the tool class
:return: whether the function is available
""" """
return self._exposed_tools.contains_tool_name(tool_name) is_active_tool = self._active_tools.contains_tool_class(tool_class)
if self._agent_interface == AgentInterface.TOOLS:
return is_active_tool
elif self._agent_interface == AgentInterface.REPL:
return is_active_tool or self.get_repl().entrypoint.is_tool_function_available(tool_class)
else:
raise NotImplementedError
def get_current_config_overview(self) -> str: def get_current_config_overview(self) -> str:
""" """
@@ -1392,12 +1578,13 @@ class SerenaAgent:
result_str += f"Active project: {self._active_project.project_name}\n" result_str += f"Active project: {self._active_project.project_name}\n"
else: else:
result_str += "No active project\n" result_str += "No active project\n"
result_str += f"Language backend: {self._language_backend.value}" result_str += f"Agent interface: {self._agent_interface.value}\n"
result_str += f"Language backend: {self._language_backend.get_key()}"
if self._active_project and self._active_project.project_config.language_backend is not None: if self._active_project and self._active_project.project_config.language_backend is not None:
result_str += " (project override)" result_str += " (project override)"
result_str += f" (global default: {self.serena_config.language_backend.value})\n" result_str += f" (global default: {self.serena_config.language_backend.get_key()})\n"
if self._language_backend.is_lsp() and self._active_project: if self._active_project:
result_str += f"Language server status: {self._active_project.get_language_server_manager_status()}\n" result_str += self._language_backend.get_config_overview_statement(self._active_project)
result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n" result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n"
result_str += f"Active context: {self._context.name}\n" result_str += f"Active context: {self._context.name}\n"
@@ -1457,7 +1644,7 @@ class SerenaAgent:
self.issue_task(lambda: self.get_active_project_or_raise().remove_language_server(ls_id), name=f"RemoveLanguage:{ls_id.get_key()}") self.issue_task(lambda: self.get_active_project_or_raise().remove_language_server(ls_id), name=f"RemoveLanguage:{ls_id.get_key()}")
def get_tool(self, tool_class: type[TTool]) -> TTool: def get_tool(self, tool_class: type[TTool]) -> TTool:
return self._all_tools[tool_class] return cast(TTool, self._all_tools[tool_class])
def print_tool_overview(self) -> None: def print_tool_overview(self) -> None:
ToolRegistry().print_tool_overview(self._active_tools.tools) ToolRegistry().print_tool_overview(self._active_tools.tools)
+7 -2
View File
@@ -9,10 +9,15 @@ from collections import defaultdict
from copy import copy from copy import copy
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING
from anthropic.types import MessageParam, MessageTokensCount
from dotenv import load_dotenv from dotenv import load_dotenv
if TYPE_CHECKING:
# Imported for annotations only: loading the anthropic package costs seconds on some
# machines (see #2012) and is only needed when the Anthropic token counter is used.
from anthropic.types import MessageTokensCount
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -64,7 +69,7 @@ class AnthropicTokenCount(TokenCountEstimator):
def _send_count_tokens_request(self, text: str) -> MessageTokensCount: def _send_count_tokens_request(self, text: str) -> MessageTokensCount:
return self._anthropic_client.messages.count_tokens( return self._anthropic_client.messages.count_tokens(
model=self._model_name, model=self._model_name,
messages=[MessageParam(role="user", content=text)], messages=[{"role": "user", "content": text}],
) )
def estimate_token_count(self, text: str) -> int: def estimate_token_count(self, text: str) -> int:
+75 -47
View File
@@ -23,7 +23,7 @@ from serena import serena_version
from serena.config.client_setup import client_setup_handlers from serena.config.client_setup import client_setup_handlers
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import ( from serena.config.serena_config import (
LanguageBackend, AgentInterface,
ModeSelectionDefinition, ModeSelectionDefinition,
ModeSelectionDefinitionWithAddedModes, ModeSelectionDefinitionWithAddedModes,
ProjectConfig, ProjectConfig,
@@ -38,11 +38,12 @@ from serena.constants import (
SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR,
) )
from serena.language_backend import BuiltinLanguageBackend, LanguageBackendRegistry
from serena.prompt_factory import SerenaPromptFactory from serena.prompt_factory import SerenaPromptFactory
from serena.tools import ActivateProjectTool from serena.tools import ActivateProjectTool
from serena.util.cli_util import AutoRegisteringGroup from serena.util.cli_util import AutoRegisteringGroup
from serena.util.logging import MemoryLogHandler from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike from solidlsp.ls_config import LanguageServerIdLike, LanguageServerRegistry
from solidlsp.ls_types import SymbolKind from solidlsp.ls_types import SymbolKind
from solidlsp.util.subprocess_util import subprocess_kwargs from solidlsp.util.subprocess_util import subprocess_kwargs
@@ -180,14 +181,14 @@ class TopLevelCommands(AutoRegisteringGroup):
@click.option( @click.option(
"--language-backend", "--language-backend",
"-b", "-b",
type=click.Choice([b.value for b in LanguageBackend]), type=click.Choice([b.value for b in BuiltinLanguageBackend]),
default=LanguageBackend.LSP.value, default=BuiltinLanguageBackend.LSP.value,
show_default=True, show_default=True,
help="Default code intelligence backend (can be overridden in the project config).", help="Default code intelligence backend (can be overridden in the project config).",
) )
def init(language_backend: Literal["LSP", "JetBrains"] = "LSP") -> None: def init(language_backend: Literal["LSP", "JetBrains"] = "LSP") -> None:
click.echo(f"\nSerena version: {serena_version()}\n") click.echo(f"\nSerena version: {serena_version()}\n")
serena_config = SerenaConfig.init(language_backend=LanguageBackend(language_backend)) serena_config = SerenaConfig.init(builtin_language_backend=BuiltinLanguageBackend(language_backend))
click.echo(f"Configuration file: {serena_config.config_file_path}") click.echo(f"Configuration file: {serena_config.config_file_path}")
click.echo(f"Language backend: {language_backend}") click.echo(f"Language backend: {language_backend}")
@@ -259,10 +260,17 @@ class TopLevelCommands(AutoRegisteringGroup):
) )
@click.option( @click.option(
"--language-backend", "--language-backend",
type=click.Choice([lb.value for lb in LanguageBackend]), type=click.Choice(LanguageBackendRegistry.get_instance().get_keys()),
default=None, default=None,
help="Override the configured language backend.", help="Override the configured language backend.",
) )
@click.option(
"--agent-interface",
type=click.Choice([i.value for i in AgentInterface], case_sensitive=False),
default=None,
help="Override the configured agent interface: 'tools' (one tool per operation) or "
"'REPL' (Python code execution via the serena_repl tool, with a fixed set of tools).",
)
@click.option( @click.option(
"--transport", "--transport",
type=click.Choice(["stdio", "sse", "streamable-http"]), type=click.Choice(["stdio", "sse", "streamable-http"]),
@@ -325,6 +333,7 @@ class TopLevelCommands(AutoRegisteringGroup):
default_modes: Sequence[str], default_modes: Sequence[str],
added_modes: Sequence[str], added_modes: Sequence[str],
language_backend: str | None, language_backend: str | None,
agent_interface: str | None,
transport: Literal["stdio", "sse", "streamable-http"], transport: Literal["stdio", "sse", "streamable-http"],
host: str, host: str,
port: int, port: int,
@@ -381,10 +390,9 @@ class TopLevelCommands(AutoRegisteringGroup):
factory = SerenaMCPFactory(transport=transport, context=context, project=project_file, memory_log_handler=memory_log_handler) factory = SerenaMCPFactory(transport=transport, context=context, project=project_file, memory_log_handler=memory_log_handler)
server = factory.create_mcp_server( server = factory.create_mcp_server(
host=host,
port=port,
mode_selection_def=mode_selection_def, mode_selection_def=mode_selection_def,
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, language_backend=LanguageBackendRegistry.get_instance().resolve(language_backend) if language_backend else None,
agent_interface=AgentInterface.from_str(agent_interface) if agent_interface else None,
enable_web_dashboard=enable_web_dashboard, enable_web_dashboard=enable_web_dashboard,
open_web_dashboard=open_web_dashboard, open_web_dashboard=open_web_dashboard,
enable_gui_log_window=enable_gui_log_window, enable_gui_log_window=enable_gui_log_window,
@@ -399,7 +407,11 @@ class TopLevelCommands(AutoRegisteringGroup):
project_file, project_file,
) )
log.info("Starting MCP server …") log.info("Starting MCP server …")
server.run(transport=transport) kwargs = {}
if transport != "stdio":
kwargs["host"] = host
kwargs["port"] = port
server.run(transport=transport, **kwargs)
@staticmethod @staticmethod
@click.command( @click.command(
@@ -708,14 +720,15 @@ class ProjectCommands(AutoRegisteringGroup):
if os.path.exists(yml_path): if os.path.exists(yml_path):
raise FileExistsError(f"Project file {yml_path} already exists.") raise FileExistsError(f"Project file {yml_path} already exists.")
languages: list[LanguageServerId] = [] languages: list[LanguageServerIdLike] = []
if language: if language:
registry = LanguageServerRegistry.get_instance()
for lang in language: for lang in language:
ls_key = lang.lower()
try: try:
languages.append(LanguageServerId(lang.lower())) languages.append(registry.resolve(ls_key))
except ValueError: except ValueError:
all_langs = [l.value for l in LanguageServerId] raise ValueError(f"Unknown language '{lang}'. Supported: {registry.get_keys()}")
raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate( generated_conf = ProjectConfig.autogenerate(
project_root=project_path, project_root=project_path,
@@ -766,6 +779,27 @@ class ProjectCommands(AutoRegisteringGroup):
except ValueError as e: except ValueError as e:
raise click.ClickException(str(e)) raise click.ClickException(str(e))
@staticmethod
@click.command(
"remove",
help="Remove a project from Serena's project registry. "
"The project's own files, including its project configuration, are left untouched.",
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
)
@click.argument("project", type=PROJECT_TYPE)
def remove(project: str) -> None:
serena_config = SerenaConfig.from_config_file()
registered_project_names = serena_config.project_names
try:
registered_project = serena_config.get_registered_project(project)
except ValueError as e:
# raised when the name is ambiguous; the message names the candidate locations
raise click.ClickException(str(e))
if registered_project is None:
raise click.ClickException(f"No registered project found for '{project}'; registered project names: {registered_project_names}")
serena_config.remove_registered_project(registered_project)
click.echo(f"Removed project '{registered_project.project_name}' ({registered_project.project_root}) from the project registry.")
@staticmethod @staticmethod
@click.command( @click.command(
"index", "index",
@@ -932,12 +966,12 @@ class ProjectCommands(AutoRegisteringGroup):
# NOTE: completely written by Claude Code, only functionality was reviewed, not implementation # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.project import Project from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool from serena.repl.api.lsp_api import LspApi
logging.configure(level=logging.INFO) logging.configure(level=logging.INFO)
project_path = os.path.abspath(project) project_path = os.path.abspath(project)
serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides()
serena_config.language_backend = LanguageBackend.LSP serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
proj = Project.load(project_path, serena_config=serena_config) proj = Project.load(project_path, serena_config=serena_config)
# Create log file with timestamp # Create log file with timestamp
@@ -977,61 +1011,55 @@ class ProjectCommands(AutoRegisteringGroup):
if not target_file: if not target_file:
raise ProjectCommands._HealthCheckFailure("No analyzable files found") raise ProjectCommands._HealthCheckFailure("No analyzable files found")
# Get tools from agent api = LspApi(agent)
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
find_symbol_tool = agent.get_tool(FindSymbolTool)
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
# Test 1: Get symbols overview # Test 1: symbols overview
log.info("Testing GetSymbolsOverviewTool on file: %s", target_file) log.info("Testing get_symbols_overview on file: %s", target_file)
overview_data = agent.execute_task(lambda: overview_tool.get_symbol_overview(target_file)) overview = agent.execute_task(lambda: api.get_symbols_overview(target_file))
log.info(f"GetSymbolsOverviewTool returned: {overview_data}") log.info(f"get_symbols_overview returned: {overview.represent()}")
if not overview_data: if len(overview) == 0:
raise ProjectCommands._HealthCheckFailure(f"No symbols found in target file {target_file}") raise ProjectCommands._HealthCheckFailure(f"No symbols found in target file {target_file}")
# Extract suitable symbol (prefer class or function over variables) # Extract suitable symbol (prefer class or function over variables)
preferred_kinds = {SymbolKind.Class.name, SymbolKind.Function.name, SymbolKind.Method.name, SymbolKind.Constructor.name} preferred_kinds = {SymbolKind.Class, SymbolKind.Function, SymbolKind.Method, SymbolKind.Constructor}
selected_symbol = None selected_symbol = next((s for s in overview.symbols if s.symbol_kind in preferred_kinds), None)
for symbol in overview_data:
if symbol.get("kind") in preferred_kinds:
selected_symbol = symbol
break
# If no preferred symbol found, use first available # If no preferred symbol found, use first available
if not selected_symbol: if selected_symbol is None:
selected_symbol = overview_data[0] selected_symbol = overview.symbols[0]
log.info("No class or function found, using first available symbol") log.info("No class or function found, using first available symbol")
symbol_name = selected_symbol["name"] symbol_name = selected_symbol.name
symbol_kind = selected_symbol["kind"] log.info("Using symbol for testing: %s (kind: %s)", symbol_name, selected_symbol.symbol_kind_name)
log.info("Using symbol for testing: %s (kind: %s)", symbol_name, symbol_kind)
# Test 2: FindSymbolTool # Test 2: find_symbol
log.info("Testing FindSymbolTool for symbol: %s", symbol_name) log.info("Testing find_symbol for symbol: %s", symbol_name)
with find_symbol_tool.symbol_dict_grouper.disabled_context(): with LspApi.find_symbol_dict_grouper_.disabled_context():
find_symbol_result = agent.execute_task( find_symbol_result = agent.execute_task(
lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True) lambda: api.find_symbol(symbol_name, relative_path=target_file, include_body=True).represent()
) )
find_symbol_data = json.loads(find_symbol_result) find_symbol_data = json.loads(find_symbol_result)
log.info("FindSymbolTool found %d matches for symbol %s", len(find_symbol_data), symbol_name) log.info("find_symbol found %d matches for symbol %s", len(find_symbol_data), symbol_name)
if not find_symbol_data: if not find_symbol_data:
raise ProjectCommands._HealthCheckFailure("FindSymbolTool returned no results") raise ProjectCommands._HealthCheckFailure("FindSymbolTool returned no results")
# Test 3: FindReferencingSymbolsTool # Test 3: find_referencing_symbols
log.info("Testing FindReferencingSymbolsTool for symbol: %s", symbol_name) log.info("Testing find_referencing_symbols for symbol: %s", symbol_name)
try: try:
with find_refs_tool.symbol_dict_grouper.disabled_context(): with LspApi.references_grouper_.disabled_context():
find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file)) find_refs_result = agent.execute_task(
lambda: api.find_referencing_symbols(symbol_name, relative_path=target_file).represent()
)
find_refs_data = json.loads(find_refs_result) find_refs_data = json.loads(find_refs_result)
log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name) log.info("find_referencing_symbols found %d references for symbol %s", len(find_refs_data), symbol_name)
except Exception as e: except Exception as e:
# A symbol with no references at all is a legitimate result, so the number of # A symbol with no references at all is a legitimate result, so the number of
# references is not asserted - but a *failure* of the reference search means the # references is not asserted - but a *failure* of the reference search means the
# language server is not functional, which is the single thing this command is # language server is not functional, which is the single thing this command is
# asked to determine. Logging it as a warning let the command print # asked to determine. Logging it as a warning let the command print
# "All tools working correctly" and exit 0 after the search had already failed. # "All tools working correctly" and exit 0 after the search had already failed.
raise ProjectCommands._HealthCheckFailure(f"FindReferencingSymbolsTool failed for symbol {symbol_name}: {e}") from e raise ProjectCommands._HealthCheckFailure(f"find_referencing_symbols failed for symbol {symbol_name}: {e}") from e
log.info("Health check completed successfully") log.info("Health check completed successfully")
+48 -4
View File
@@ -6,7 +6,8 @@ import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Iterable, Iterator, Reversible from collections.abc import Iterable, Iterator, Reversible
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generic, TypeVar, cast from types import TracebackType
from typing import Any, Generic, Self, TypeVar, cast
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol
@@ -16,6 +17,7 @@ from solidlsp.ls_utils import PathUtils, TextStepper, TextUtils
from .project import Project from .project import Project
from .util.file_proxy import FileProxy from .util.file_proxy import FileProxy
from .util.file_system import write_file_atomic
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
TSymbol = TypeVar("TSymbol", bound=Symbol) TSymbol = TypeVar("TSymbol", bound=Symbol)
@@ -23,6 +25,7 @@ TSymbol = TypeVar("TSymbol", bound=Symbol)
class CodeEditor(Generic[TSymbol], ABC): class CodeEditor(Generic[TSymbol], ABC):
def __init__(self, project: Project) -> None: def __init__(self, project: Project) -> None:
self.project = project
self.project_root = project.project_root self.project_root = project.project_root
self.encoding = project.project_config.encoding self.encoding = project.project_config.encoding
self.newline = project.line_ending.newline_str self.newline = project.line_ending.newline_str
@@ -81,7 +84,7 @@ class CodeEditor(Generic[TSymbol], ABC):
""" """
Context manager for editing a file. Context manager for editing a file.
""" """
if FileProxy.is_external_path(relative_path): if FileProxy.is_external_path(relative_path, self.project):
raise ValueError(f"Cannot edit external file: {relative_path}") raise ValueError(f"Cannot edit external file: {relative_path}")
with self._open_file_context(relative_path) as edited_file: with self._open_file_context(relative_path) as edited_file:
yield edited_file yield edited_file
@@ -91,8 +94,7 @@ class CodeEditor(Generic[TSymbol], ABC):
def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None: def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None:
abs_path = os.path.join(self.project_root, edited_file.relative_path) abs_path = os.path.join(self.project_root, edited_file.relative_path)
new_contents = edited_file.get_contents() new_contents = edited_file.get_contents()
with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f: write_file_atomic(abs_path, new_contents, encoding=self.encoding, newline=self.newline)
f.write(new_contents)
@abstractmethod @abstractmethod
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol:
@@ -493,3 +495,45 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]):
rename_in_text_occurrences=rename_in_text_occurrences, rename_in_text_occurrences=rename_in_text_occurrences,
) )
return "Success" return "Success"
class EditedFileContext:
"""
Context manager for file editing.
Create the context, then use `set_updated_content` to set the new content, the original content
being provided in `original_content`.
When exiting the context without an exception, the updated content will be written back to the file.
"""
def __init__(self, relative_path: str, code_editor: CodeEditor):
self._relative_path = relative_path
self._code_editor = code_editor
self._edited_file: CodeEditor.EditedFile | None = None
self._edited_file_context: Any = None
def __enter__(self) -> Self:
self._edited_file_context = self._code_editor.edited_file_context(self._relative_path)
self._edited_file = self._edited_file_context.__enter__()
return self
def get_original_content(self) -> str:
"""
:return: the original content of the file before any modifications.
"""
assert self._edited_file is not None
return self._edited_file.get_contents()
def set_updated_content(self, content: str) -> None:
"""
Sets the updated content of the file, which will be written back to the file
when the context is exited without an exception.
:param content: the updated content of the file
"""
assert self._edited_file is not None
self._edited_file.set_contents(content)
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
assert self._edited_file_context is not None
self._edited_file_context.__exit__(exc_type, exc_value, traceback)
+3 -3
View File
@@ -12,7 +12,7 @@ import yaml
from sensai.util import logging from sensai.util import logging
from sensai.util.string import ToStringMixin from sensai.util.string import ToStringMixin
from serena.config.serena_config import SerenaPaths, ToolInclusionDefinition from serena.config.serena_config import ApiInclusionDefinition, SerenaPaths, ToolInclusionDefinition
from serena.constants import ( from serena.constants import (
DEFAULT_CONTEXT, DEFAULT_CONTEXT,
INTERNAL_MODE_YAMLS_DIR, INTERNAL_MODE_YAMLS_DIR,
@@ -32,7 +32,7 @@ def looks_like_yaml_path(s: str) -> bool:
@dataclass(kw_only=True) @dataclass(kw_only=True)
class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): class SerenaAgentMode(ToolInclusionDefinition, ApiInclusionDefinition, ToStringMixin):
"""Represents a mode of operation for the agent, typically read off a YAML file. """Represents a mode of operation for the agent, typically read off a YAML file.
An agent can be in multiple modes simultaneously as long as they are not mutually exclusive. An agent can be in multiple modes simultaneously as long as they are not mutually exclusive.
The modes can be adjusted after the agent is running, for example for switching from planning to editing. The modes can be adjusted after the agent is running, for example for switching from planning to editing.
@@ -148,7 +148,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
@dataclass(kw_only=True) @dataclass(kw_only=True)
class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): class SerenaAgentContext(ToolInclusionDefinition, ApiInclusionDefinition, ToStringMixin):
"""Represents a context where the agent is operating (an IDE, a chat, etc.), typically read off a YAML file. """Represents a context where the agent is operating (an IDE, a chat, etc.), typically read off a YAML file.
An agent can only be in a single context at a time. An agent can only be in a single context at a time.
The contexts cannot be changed after the agent is running. The contexts cannot be changed after the agent is running.
+165 -67
View File
@@ -7,15 +7,16 @@ import dataclasses
import os import os
import re import re
import shutil import shutil
import stat
import threading import threading
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import Enum
from functools import cached_property
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar
from uuid import uuid4
import yaml import yaml
from ruamel.yaml.comments import CommentedMap from ruamel.yaml.comments import CommentedMap
@@ -36,16 +37,16 @@ from serena.constants import (
from serena.util.inspection import compute_language_server_support_composition from serena.util.inspection import compute_language_server_support_composition
from serena.util.text_utils import GlobMatcher from serena.util.text_utils import GlobMatcher
from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_yaml_comments from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_yaml_comments
from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike, LanguageServerRegistry from solidlsp.ls_config import LanguageServerIdLike, LanguageServerRegistry
from ..analytics import RegisteredTokenCountEstimator from ..analytics import RegisteredTokenCountEstimator
from ..language_backend import BuiltinLanguageBackend, LanguageBackend, LanguageBackendRegistry
from ..util.class_decorators import singleton from ..util.class_decorators import singleton
from ..util.cli_util import ask_yes_no from ..util.cli_util import ask_yes_no
from ..util.dataclass import get_dataclass_default from ..util.dataclass import get_dataclass_default
if TYPE_CHECKING: if TYPE_CHECKING:
from ..project import Project from ..project import Project
from ..tools.tools_base import Tool
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")
@@ -178,6 +179,26 @@ class NamedToolInclusionDefinition(ToolInclusionDefinition):
return f"ToolInclusionDefinition[{self.name}]" return f"ToolInclusionDefinition[{self.name}]"
@dataclass
class ApiInclusionDefinition:
"""
Defines which APIs to include/exclude in Serena's operation.
A single API inclusion/exclusion can either be a full facade (facade name, which encompasses all of its methods, e.g. "lsp")
or a method of a facade (facade name + method name, e.g. "lsp.find_symbol").
"""
included_apis: Sequence[str] = ()
excluded_apis: Sequence[str] = ()
@dataclass
class NamedApiInclusionDefinition(ApiInclusionDefinition):
name: str | None = None
def __str__(self) -> str:
return f"ApiInclusionDefinition[{self.name}]"
@dataclass @dataclass
class ModeSelectionDefinition: class ModeSelectionDefinition:
default_modes: Sequence[str] | None = None default_modes: Sequence[str] | None = None
@@ -196,52 +217,35 @@ class ModeSelectionDefinitionWithAddedModes(ModeSelectionDefinition):
added_modes: Sequence[str] | None = None added_modes: Sequence[str] | None = None
class LanguageBackend(Enum): class AgentInterface(Enum):
LSP = "LSP"
""" """
Use the language server protocol (LSP), spawning freely available language servers The interface through which the agent (LLM) accesses Serena's functionality.
via the SolidLSP library that is part of Serena
""" """
JETBRAINS = "JetBrains"
TOOLS = "tools"
""" """
Use the Serena plugin in your JetBrains IDE. The classic tool interface: each operation is a separate tool, and the set of tools is configurable
(requires the plugin to be installed and the project being worked on to be open in your IDE) (via tool inclusions/exclusions in the configuration, context, modes and project).
"""
REPL = "REPL"
"""
The REPL interface: operations are accessed programmatically via the serena_repl tool, which executes Python code.
The set of tools is fixed (the REPL tool and the tools required for session management) and tool inclusions/exclusions
do not apply; the operations available in the REPL are configured via API inclusions/exclusions instead.
""" """
@staticmethod @staticmethod
def from_str(backend_str: str) -> "LanguageBackend": def from_str(interface_str: str) -> "AgentInterface":
for backend in LanguageBackend: for interface in AgentInterface:
if backend.value.lower() == backend_str.lower(): if interface.value.lower() == interface_str.lower():
return backend return interface
raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") raise ValueError(f"Unknown agent interface '{interface_str}': valid values are {[i.value for i in AgentInterface]}")
def is_lsp(self) -> bool: def is_tools(self) -> bool:
return self == LanguageBackend.LSP return self == AgentInterface.TOOLS
def is_jetbrains(self) -> bool: def is_repl(self) -> bool:
return self == LanguageBackend.JETBRAINS return self == AgentInterface.REPL
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
"""
:return: mapping from LSP tool classes to replacement tool classes (functional replacements)
"""
match self:
case LanguageBackend.LSP:
return {}
case LanguageBackend.JETBRAINS:
from ..tools import jetbrains_tools, symbol_tools
return {
symbol_tools.FindSymbolTool: jetbrains_tools.JetBrainsFindSymbolTool,
symbol_tools.GetSymbolsOverviewTool: jetbrains_tools.JetBrainsGetSymbolsOverviewTool,
symbol_tools.FindReferencingSymbolsTool: jetbrains_tools.JetBrainsFindReferencingSymbolsTool,
symbol_tools.FindImplementationsTool: jetbrains_tools.JetBrainsFindImplementationsTool,
symbol_tools.FindDeclarationTool: jetbrains_tools.JetBrainsFindDeclarationTool,
symbol_tools.RenameSymbolTool: jetbrains_tools.JetBrainsRenameTool,
symbol_tools.SafeDeleteSymbol: jetbrains_tools.JetBrainsSafeDeleteTool,
}
case _:
raise NotImplementedError()
class LineEnding(Enum): class LineEnding(Enum):
@@ -274,7 +278,7 @@ class LineEnding(Enum):
@dataclass @dataclass
class SharedConfig(ToolInclusionDefinition, ToStringMixin): class SharedConfig(ToolInclusionDefinition, ApiInclusionDefinition, ToStringMixin):
"""Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form
(same as in ModeSelectionDefinition). (same as in ModeSelectionDefinition).
The defaults here shall be none and should be set to the global default values in SerenaConfig. The defaults here shall be none and should be set to the global default values in SerenaConfig.
@@ -282,6 +286,7 @@ class SharedConfig(ToolInclusionDefinition, ToStringMixin):
symbol_info_budget: float | None = None symbol_info_budget: float | None = None
language_backend: LanguageBackend | None = None language_backend: LanguageBackend | None = None
agent_interface: AgentInterface | None = None
line_ending: LineEnding | None = None line_ending: LineEnding | None = None
read_only_memory_patterns: list[str] = field(default_factory=list) read_only_memory_patterns: list[str] = field(default_factory=list)
ignored_memory_patterns: list[str] = field(default_factory=list) ignored_memory_patterns: list[str] = field(default_factory=list)
@@ -363,11 +368,15 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
log.info("Determining suitable language servers for the project") log.info("Determining suitable language servers for the project")
# determine language servers to be considered and their priorities # determine language servers to be considered and their priorities
ls_priorities = {} # the registry is the single source of truth — it includes both built-in enum members
for language in LanguageServerId: # and externally-registered adapters (via solidlsp.language_server_registration entry points).
priority = serena_config.get_ls_priority(language) # priorities are user-configurable per-key via serena_config.ls_priorities (works for both kinds).
ls_priorities: dict[LanguageServerIdLike, int] = {}
registry = LanguageServerRegistry.get_instance()
for ls_id in registry.iter_registered_ls_ids():
priority = serena_config.get_ls_priority(ls_id)
if priority > 0: if priority > 0:
ls_priorities[language] = priority ls_priorities[ls_id] = priority
log.debug("Language server priorities: %s", ls_priorities) log.debug("Language server priorities: %s", ls_priorities)
ls_composition = compute_language_server_support_composition(project_root, list(ls_priorities.keys())) ls_composition = compute_language_server_support_composition(project_root, list(ls_priorities.keys()))
@@ -394,7 +403,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
if len(other_language_pairs) > 0 and interactive: if len(other_language_pairs) > 0 and interactive:
print( print(
"Detected and enabled main language server '%s' (%.2f%% of source files)." "Detected and enabled main language server '%s' (%.2f%% of source files)."
% (top_language_pair[0].value, top_language_pair[1]) % (top_language_pair[0].get_key(), top_language_pair[1])
) )
print(f"Additionally detected {len(other_language_pairs)} other applicable language servers.\n") print(f"Additionally detected {len(other_language_pairs)} other applicable language servers.\n")
print("Note: Enable only servers for languages you need symbolic retrieval/editing capabilities for.") print("Note: Enable only servers for languages you need symbolic retrieval/editing capabilities for.")
@@ -402,7 +411,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
print(" system-level installations/configuration (see Serena documentation).") print(" system-level installations/configuration (see Serena documentation).")
print("\nWhich additional language servers do you want to enable?") print("\nWhich additional language servers do you want to enable?")
for ls_id, perc in other_language_pairs: for ls_id, perc in other_language_pairs:
enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (ls_id.value, perc), default=False) enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (ls_id.get_key(), perc), default=False)
if enable: if enable:
language_servers_to_use.append(ls_id) language_servers_to_use.append(ls_id)
print() print()
@@ -416,7 +425,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
project_root: str | Path, project_root: str | Path,
serena_config: "SerenaConfig", serena_config: "SerenaConfig",
project_name: str | None = None, project_name: str | None = None,
languages: list[LanguageServerId] | None = None, languages: list[LanguageServerIdLike] | None = None,
save_to_disk: bool = True, save_to_disk: bool = True,
interactive: bool = False, interactive: bool = False,
asynchronous: bool = False, asynchronous: bool = False,
@@ -455,7 +464,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
) )
languages_to_use = [l.get_key() for l in determined_languages] languages_to_use = [l.get_key() for l in determined_languages]
else: else:
languages_to_use = [lang.value for lang in languages] languages_to_use = [lang.get_key() for lang in languages]
config_with_comments, _ = cls._load_yaml_dict(PROJECT_TEMPLATE_FILE) config_with_comments, _ = cls._load_yaml_dict(PROJECT_TEMPLATE_FILE)
config_with_comments["project_name"] = project_name config_with_comments["project_name"] = project_name
config_with_comments["language_servers"] = languages_to_use config_with_comments["language_servers"] = languages_to_use
@@ -611,7 +620,9 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
raise ValueError(f"symbol_info_budget cannot be negative, got: {symbol_info_budget}") raise ValueError(f"symbol_info_budget cannot be negative, got: {symbol_info_budget}")
language_backend_value = data.get("language_backend") language_backend_value = data.get("language_backend")
language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None language_backend = LanguageBackendRegistry.get_instance().resolve(language_backend_value) if language_backend_value else None
agent_interface_value = data.get("agent_interface")
agent_interface = AgentInterface.from_str(agent_interface_value) if agent_interface_value else None
line_ending_value = data.get("line_ending") line_ending_value = data.get("line_ending")
line_ending = LineEnding.from_str(line_ending_value) if line_ending_value else None line_ending = LineEnding.from_str(line_ending_value) if line_ending_value else None
@@ -621,6 +632,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
fixed_tools = data["fixed_tools"] or [] fixed_tools = data["fixed_tools"] or []
excluded_tools = data["excluded_tools"] or [] excluded_tools = data["excluded_tools"] or []
included_optional_tools = data["included_optional_tools"] or [] included_optional_tools = data["included_optional_tools"] or []
excluded_apis = data.get("excluded_apis") or []
included_apis = data.get("included_apis") or []
additional_workspace_folders = data.get("ls_additional_workspace_folders") or [] additional_workspace_folders = data.get("ls_additional_workspace_folders") or []
if "base_modes" in data and data["base_modes"] is not None: if "base_modes" in data and data["base_modes"] is not None:
@@ -635,6 +648,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
excluded_tools=excluded_tools, excluded_tools=excluded_tools,
fixed_tools=fixed_tools, fixed_tools=fixed_tools,
included_optional_tools=included_optional_tools, included_optional_tools=included_optional_tools,
excluded_apis=excluded_apis,
included_apis=included_apis,
read_only=data["read_only"], read_only=data["read_only"],
read_only_memory_patterns=data.get("read_only_memory_patterns", []), read_only_memory_patterns=data.get("read_only_memory_patterns", []),
ignored_memory_patterns=data.get("ignored_memory_patterns", []), ignored_memory_patterns=data.get("ignored_memory_patterns", []),
@@ -643,6 +658,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
encoding=data["encoding"], encoding=data["encoding"],
line_ending=line_ending, line_ending=line_ending,
language_backend=language_backend, language_backend=language_backend,
agent_interface=agent_interface,
added_modes=data["added_modes"], added_modes=data["added_modes"],
default_modes=data["default_modes"], default_modes=data["default_modes"],
symbol_info_budget=symbol_info_budget, symbol_info_budget=symbol_info_budget,
@@ -666,7 +682,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
# map fields using non-primitive types to a YAML-compatible representation # map fields using non-primitive types to a YAML-compatible representation
d["language_servers"] = [lang.get_key() for lang in self.language_servers] d["language_servers"] = [lang.get_key() for lang in self.language_servers]
d["language_backend"] = self.language_backend.value if self.language_backend is not None else None d["language_backend"] = self.language_backend.get_key() if self.language_backend is not None else None
d["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None
d["line_ending"] = self.line_ending.value if self.line_ending is not None else None d["line_ending"] = self.line_ending.value if self.line_ending is not None else None
return d return d
@@ -870,6 +887,11 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
# *** fields that are mapped directly to/from the configuration file (DO NOT RENAME) *** # *** fields that are mapped directly to/from the configuration file (DO NOT RENAME) ***
projects: list[RegisteredProject] = field(default_factory=list) projects: list[RegisteredProject] = field(default_factory=list)
auth_secret: str = field(default_factory=lambda: str(uuid4()), repr=False)
"""
shared secret for authenticating communication between Serena components and services.
A random UUID is generated and persisted when the configuration setting is missing or empty.
"""
gui_log_window: bool = False gui_log_window: bool = False
log_level: int = logging.INFO log_level: int = logging.INFO
trace_lsp_communication: bool = False trace_lsp_communication: bool = False
@@ -932,7 +954,13 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
# settings with overridden defaults # settings with overridden defaults
language_backend: LanguageBackend = LanguageBackend.LSP agent_interface: AgentInterface = AgentInterface.TOOLS
"""
the agent interface to use (unless overridden by the active project's configuration).
Defaults to TOOLS for backward compatibility (as users without this settings will get this default).
The default for new users is defined in the template file.
"""
language_backend: LanguageBackend = field(default_factory=lambda: BuiltinLanguageBackend.LSP.get_instance())
""" """
the language backend to use for code understanding features the language backend to use for code understanding features
""" """
@@ -960,7 +988,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
# *** static members *** # *** static members ***
CONFIG_FILE = "serena_config.yml" CONFIG_FILE = "serena_config.yml"
CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend", "line_ending"} CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend", "agent_interface", "line_ending"}
# *** methods *** # *** methods ***
@classmethod @classmethod
@@ -1035,6 +1063,17 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...")
cls._generate_config_file(config_file_path) cls._generate_config_file(config_file_path)
# restrict access to the owner's read/write permissions (as the config file contains secrets)
if os.name == "posix":
current_mode = stat.S_IMODE(os.stat(config_file_path).st_mode)
if current_mode != 0o600:
try:
os.chmod(config_file_path, 0o600)
except Exception as e:
log.error("Failed to restrict permissions of Serena configuration %s to 0600: %s", config_file_path, e)
else:
log.info("Changed permissions of Serena configuration %s from %04o to 0600", config_file_path, current_mode)
# load the configuration # load the configuration
log.info(f"Loading Serena configuration from {config_file_path}") log.info(f"Loading Serena configuration from {config_file_path}")
try: try:
@@ -1057,6 +1096,11 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
assert hasattr(instance, field_name) assert hasattr(instance, field_name)
setattr(instance, field_name, get_value_or_default(field_name)) setattr(instance, field_name, get_value_or_default(field_name))
# generate a persistent authentication secret for explicitly unset settings
if not instance.auth_secret:
instance.auth_secret = str(uuid4())
num_migrations += 1
# read projects # read projects
if "projects" not in loaded_commented_yaml: if "projects" not in loaded_commented_yaml:
raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.")
@@ -1100,16 +1144,25 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
language_backend = get_dataclass_default(SerenaConfig, "language_backend") language_backend = get_dataclass_default(SerenaConfig, "language_backend")
if "language_backend" in loaded_commented_yaml: if "language_backend" in loaded_commented_yaml:
backend_str = loaded_commented_yaml["language_backend"] backend_str = loaded_commented_yaml["language_backend"]
language_backend = LanguageBackend.from_str(backend_str) language_backend = LanguageBackendRegistry.get_instance().resolve(backend_str)
else: else:
# backward compatibility (migrate Boolean field "jetbrains") # backward compatibility (migrate Boolean field "jetbrains")
if "jetbrains" in loaded_commented_yaml: if "jetbrains" in loaded_commented_yaml:
num_migrations += 1 num_migrations += 1
if loaded_commented_yaml["jetbrains"]: if loaded_commented_yaml["jetbrains"]:
language_backend = LanguageBackend.JETBRAINS language_backend = BuiltinLanguageBackend.JETBRAINS.get_instance()
del loaded_commented_yaml["jetbrains"] del loaded_commented_yaml["jetbrains"]
instance.language_backend = language_backend instance.language_backend = language_backend
# determine agent interface
agent_interface: AgentInterface | None = get_dataclass_default(SerenaConfig, "agent_interface")
if "agent_interface" in loaded_commented_yaml:
agent_interface_value = loaded_commented_yaml["agent_interface"]
agent_interface = AgentInterface.from_str(agent_interface_value)
else:
num_migrations += 1
instance.agent_interface = agent_interface
# determine line ending # determine line ending
line_ending_value = loaded_commented_yaml.get("line_ending") line_ending_value = loaded_commented_yaml.get("line_ending")
if line_ending_value: if line_ending_value:
@@ -1164,17 +1217,25 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
log.error(f"Error migrating configuration file: {e}") log.error(f"Error migrating configuration file: {e}")
return None return None
def set_builtin_language_backend(self, backend: BuiltinLanguageBackend) -> None:
"""
Sets the built-in language backend to use for code understanding features.
:param backend: the language backend to set
"""
self.language_backend = backend.get_instance()
@classmethod @classmethod
def init(cls, language_backend: LanguageBackend) -> "SerenaConfig": def init(cls, builtin_language_backend: BuiltinLanguageBackend) -> "SerenaConfig":
""" """
Supports the config initialisation CLI command, allowing the user to configure fundamental settings before Supports the config initialisation CLI command, allowing the user to configure fundamental settings before
the first launch. the first launch.
:param language_backend: the language backend to use :param builtin_language_backend: the language backend to use
:return: the created SerenaConfig instance :return: the created SerenaConfig instance
""" """
config = cls.from_config_file() config = cls.from_config_file()
config.language_backend = language_backend config.language_backend = builtin_language_backend.get_instance()
config._save() config._save()
return config return config
@@ -1191,11 +1252,11 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
self.jetbrains_launch_command = None self.jetbrains_launch_command = None
return self return self
@cached_property @property
def project_paths(self) -> list[str]: def project_paths(self) -> list[str]:
return sorted(str(project.project_root) for project in self.projects) return sorted(str(project.project_root) for project in self.projects)
@cached_property @property
def project_names(self) -> list[str]: def project_names(self) -> list[str]:
return sorted(project.project_config.project_name for project in self.projects) return sorted(project.project_config.project_name for project in self.projects)
@@ -1247,6 +1308,21 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
self.projects.append(registered_project) self.projects.append(registered_project)
self._persist_projects() self._persist_projects()
def remove_registered_project(self, registered_project: RegisteredProject) -> None:
"""
Removes the given registered project, persisting the updated project list.
Only the registry entry is removed; the project's own files, including its project
configuration file, are left untouched.
Unlike :meth:`remove_project`, which resolves the project by name, this removes the
given entry itself and is therefore unambiguous when several registered projects
share a name.
:param registered_project: the project to remove, which must be an element of :attr:`projects`
"""
self.projects.remove(registered_project)
self._persist_projects()
def add_project_from_path(self, project_root: Path | str, asynchronous_autogen: bool = False) -> "Project": def add_project_from_path(self, project_root: Path | str, asynchronous_autogen: bool = False) -> "Project":
""" """
Adds a new project to the Serena configuration from a given path, auto-generating the project Adds a new project to the Serena configuration from a given path, auto-generating the project
@@ -1351,7 +1427,10 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
commented_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) commented_yaml["projects"] = sorted({str(project.project_root) for project in self.projects})
# convert language backend to string # convert language backend to string
commented_yaml["language_backend"] = self.language_backend.value commented_yaml["language_backend"] = self.language_backend.get_key()
# convert agent interface to string (None if not configured)
commented_yaml["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None
# convert line ending to string # convert line ending to string
commented_yaml["line_ending"] = self.line_ending.value commented_yaml["line_ending"] = self.line_ending.value
@@ -1442,7 +1521,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
""" """
Propagate settings from this configuration to individual components that are statically configured Propagate settings from this configuration to individual components that are statically configured
""" """
from serena.tools import JetBrainsPluginClient from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
JetBrainsPluginClient.set_server_address(self.jetbrains_plugin_server_address) JetBrainsPluginClient.set_server_address(self.jetbrains_plugin_server_address)
@@ -1459,7 +1538,26 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
return True return True
return False return False
def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False): def determine_agent_interface(self, project_config: "ProjectConfig | None" = None, log_choice: bool = False) -> AgentInterface:
"""
Determines the effective agent interface: the project configuration takes precedence over the global configuration;
if neither configures an interface, the tool interface is used.
:param project_config: the configuration of the project to be activated, if any
:param log_choice: whether to log the choice
:return: the effective agent interface
"""
if project_config is not None and project_config.agent_interface is not None:
agent_interface, source = project_config.agent_interface, "project configuration"
elif self.agent_interface is not None:
agent_interface, source = self.agent_interface, "global configuration"
else:
agent_interface, source = AgentInterface.TOOLS, "default"
if log_choice:
log.info(f"Using agent interface '{agent_interface.value}' ({source})")
return agent_interface
def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False) -> LanguageBackend:
language_backend = self.language_backend language_backend = self.language_backend
if project_config and project_config.language_backend is not None: if project_config and project_config.language_backend is not None:
language_backend = project_config.language_backend language_backend = project_config.language_backend
@@ -1470,7 +1568,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
log.info(f"Using language backend from global configuration: {language_backend.name}") log.info(f"Using language backend from global configuration: {language_backend.name}")
return language_backend return language_backend
def get_ls_priority(self, ls_id: LanguageServerId) -> int: def get_ls_priority(self, ls_id: LanguageServerIdLike) -> int:
""" """
Gets the priority value associated with a language server Gets the priority value associated with a language server
@@ -1479,9 +1577,9 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
""" """
if self.ls_priorities is not None: if self.ls_priorities is not None:
try: try:
configured_value = self.ls_priorities.get(ls_id.value) configured_value = self.ls_priorities.get(ls_id.get_key())
if configured_value is not None: if configured_value is not None:
return int(configured_value) return int(configured_value)
except Exception as e: except Exception as e:
log.error("Error reading language priority for %s: %s. Using default priority.", ls_id.value, e) log.error("Error reading language priority for %s: %s. Using default priority.", ls_id.get_key(), e)
return ls_id.get_priority() return ls_id.get_priority()
+53 -3
View File
@@ -27,6 +27,7 @@ from serena.analytics import ToolUsageStats
from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.config.serena_config import SerenaConfig, SerenaPaths
from serena.constants import SERENA_DASHBOARD_DIR, SerenaPorts from serena.constants import SERENA_DASHBOARD_DIR, SerenaPorts
from serena.task_executor import TaskExecutor from serena.task_executor import TaskExecutor
from serena.tools import ReadMemoryTool
from serena.util.logging import MemoryLogHandler from serena.util.logging import MemoryLogHandler
from serena.util.pypi import PyPIPackageInfo from serena.util.pypi import PyPIPackageInfo
from serena.util.pywebview import WebViewWithTray from serena.util.pywebview import WebViewWithTray
@@ -60,11 +61,25 @@ class ResponseToolStats(BaseModel):
stats: dict[str, dict[str, int]] stats: dict[str, dict[str, int]]
class ResponseFacadeMethod(BaseModel):
name: str
is_enabled: bool
class ResponseFacade(BaseModel):
name: str
is_enabled: bool
methods: list[ResponseFacadeMethod]
class ResponseConfigOverview(BaseModel): class ResponseConfigOverview(BaseModel):
active_project: dict[str, str | None] active_project: dict[str, str | None]
context: dict[str, str] context: dict[str, str]
modes: list[dict[str, str]] modes: list[dict[str, str]]
active_tools: list[str] active_tools: list[str]
agent_interface: str
language_backend: str
facades: list[ResponseFacade] | None
tool_stats_summary: dict[str, dict[str, int]] tool_stats_summary: dict[str, dict[str, int]]
registered_projects: list[dict[str, str | bool]] registered_projects: list[dict[str, str | bool]]
available_tools: list[dict[str, str | bool]] available_tools: list[dict[str, str | bool]]
@@ -604,9 +619,22 @@ class SerenaDashboardAPI:
# Get available memories if ReadMemoryTool is active # Get available memories if ReadMemoryTool is active
available_memories = None available_memories = None
if self._agent.tool_is_active("read_memory") and project is not None: if self._agent.is_tool_function_available(ReadMemoryTool) and project is not None:
available_memories = project.memory_manager.list_memories().get_full_list() available_memories = project.memory_manager.list_memories().get_full_list()
# Get the availability of the REPL's facades and their methods (REPL interface only)
facades = None
if self._agent.get_agent_interface().is_repl():
availability_info = self._agent.get_repl().entrypoint.get_facade_availability_info()
facades = [
ResponseFacade(
name=facade_info.name,
is_enabled=facade_info.is_enabled,
methods=[ResponseFacadeMethod(name=m.name, is_enabled=m.is_enabled) for m in facade_info.methods],
)
for facade_info in availability_info.facades
]
# Get list of languages for the active project # Get list of languages for the active project
ls_ids = [] ls_ids = []
if project is not None: if project is not None:
@@ -622,6 +650,9 @@ class SerenaDashboardAPI:
context=context_info, context=context_info,
modes=modes_info, modes=modes_info,
active_tools=active_tools, active_tools=active_tools,
agent_interface=self._agent.get_agent_interface().value,
language_backend=self._agent.get_language_backend().get_key(),
facades=facades,
tool_stats_summary=tool_stats_summary, tool_stats_summary=tool_stats_summary,
registered_projects=registered_projects, registered_projects=registered_projects,
available_tools=available_tools, available_tools=available_tools,
@@ -1030,9 +1061,28 @@ class SerenaDashboardTrayManager:
log.info("Unregistered instance on port %d", port) log.info("Unregistered instance on port %d", port)
return {"status": "unregistered"} return {"status": "unregistered"}
@staticmethod
def _run_in_ui_thread(fn: Callable[[], None]) -> None:
"""
Runs a UI mutation in the thread in which the platform's UI toolkit requires it to run (where necessary).
On macOS, AppKit demands that mutations of the status item happen on the main thread, and
recent macOS versions terminate the process with SIGTRAP when they do not. The tray manager
reaches such mutations from Flask request handlers and from the alive-check thread, so the
call has to be marshalled. On other platforms it is made directly.
:param fn: the UI mutation to run
"""
if sys.platform == "darwin":
from PyObjCTools import AppHelper # ty: ignore[unresolved-import]
AppHelper.callAfter(fn)
else:
fn()
def _update_menu(self) -> None: def _update_menu(self) -> None:
if self._tray_icon: if self._tray_icon:
self._tray_icon.update_menu() self._run_in_ui_thread(self._tray_icon.update_menu)
def _build_menu_items(self) -> tuple[Any, ...]: def _build_menu_items(self) -> tuple[Any, ...]:
""" """
@@ -1190,7 +1240,7 @@ class SerenaDashboardTrayManager:
# set up tray icon with a dynamic menu (callable returns items on each open) # set up tray icon with a dynamic menu (callable returns items on each open)
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {}
if sys.platform == "darwin": if sys.platform == "darwin":
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory from AppKit import NSApplication, NSApplicationActivationPolicyAccessory # ty: ignore[unresolved-import] (macOS only)
nsapp = NSApplication.sharedApplication() nsapp = NSApplication.sharedApplication()
# run as an accessory app so that only the menu bar icon is shown (no Dock icon) # run as an accessory app so that only the menu bar icon is shown (no Dock icon)
+105
View File
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import TYPE_CHECKING
from overrides import override
from serena.code_editor import JetBrainsCodeEditor
from serena.jetbrains import launch_coordinator
from serena.language_backend import BuiltinLanguageBackend, LanguageBackend
from ..util.file_proxy import FileProxy, LocalProjectFileProxy
from . import jetbrains_types as jb
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor
from serena.project import Project
from serena.repl.facade import ApiScope, Facade
from serena.tools import Tool
log = logging.getLogger(__name__)
class LanguageBackendJetBrains(LanguageBackend):
def __init__(self):
super().__init__(BuiltinLanguageBackend.JETBRAINS.value)
@override
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
from ..tools import jetbrains_tools, symbol_tools
return {
symbol_tools.FindSymbolTool: jetbrains_tools.JetBrainsFindSymbolTool,
symbol_tools.GetSymbolsOverviewTool: jetbrains_tools.JetBrainsGetSymbolsOverviewTool,
symbol_tools.FindReferencingSymbolsTool: jetbrains_tools.JetBrainsFindReferencingSymbolsTool,
symbol_tools.FindImplementationsTool: jetbrains_tools.JetBrainsFindImplementationsTool,
symbol_tools.FindDeclarationTool: jetbrains_tools.JetBrainsFindDeclarationTool,
symbol_tools.RenameSymbolTool: jetbrains_tools.JetBrainsRenameTool,
symbol_tools.SafeDeleteSymbol: jetbrains_tools.JetBrainsSafeDeleteTool,
}
@override
def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]:
from ..repl.api.jb_api import JetBrainsApi
from ..repl.facade import Facade
return [Facade.from_api(JetBrainsApi(agent), api_scope)]
@override
def init_active_project(self, agent: "SerenaAgent") -> None:
project = agent.get_active_project_or_raise()
client = launch_coordinator.find_plugin_server(project)
if client is not None:
log.info("Found Serena JetBrains Plugin server: %s", client)
else:
log.info("Serena JetBrains Plugin server not found for project %s", project.project_name)
launch_command = agent.serena_config.jetbrains_launch_command
if launch_command:
launch_coordinator.launch_and_wait_for_plugin_server(project, launch_command)
@override
def shutdown_active_project(self, project: "Project", timeout: float) -> None:
pass
@override
def create_code_editor(self, project: "Project") -> "CodeEditor":
return JetBrainsCodeEditor(project)
@override
def is_source_file(self, abs_path: str, project: "Project") -> bool:
# no distinction is made; every file is potentially a source file
return True
@override
def is_external_path(self, relative_path: str) -> bool:
return relative_path.startswith(jb.JB_EXTERNAL_FILE_PREFIX)
@override
def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy:
if self.is_external_path(relative_path):
return JetBrainsFileProxy(relative_path, project)
return LocalProjectFileProxy(relative_path, project)
class JetBrainsFileProxy(FileProxy):
"""
Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path,
which may be an external path (e.g., "<ext:FileUtil.class|472e0a13>")
"""
def __init__(self, relative_path: str, project: "Project"):
self._relative_path = relative_path
self._project = project
def get_contents(self) -> str:
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
client = JetBrainsPluginClient.from_project(self._project)
return client.read_file(self._relative_path)
def get_relative_path(self) -> str:
return self._relative_path
def is_glob_supported(self):
return False
-8
View File
@@ -8,14 +8,6 @@ Prefix used for in relative paths of symbols that are from external libraries (i
""" """
def is_external_path(relative_path: str):
"""
:param relative_path: a relative path (e.g., from a symbol's `relative_path` field)
:return: whether the path is an external path (i.e., from a library, not the user's codebase)
"""
return relative_path.startswith(JB_EXTERNAL_FILE_PREFIX)
class PluginStatusDTO(TypedDict): class PluginStatusDTO(TypedDict):
project_root: str project_root: str
plugin_version: str plugin_version: str
+251
View File
@@ -0,0 +1,251 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
import logging
import threading
from abc import ABC, abstractmethod
from enum import Enum
from functools import cache
from typing import TYPE_CHECKING
from serena.util.file_proxy import FileProxy
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor
from serena.project import Project
from serena.repl.facade import ApiScope, Facade
from serena.tools import Tool
class LanguageBackend(ABC):
def __init__(self, key: str):
"""
:param key: the key by which the backend is identified in the registry and in configuration
"""
self._key = key
def __str__(self):
return self._key
def get_key(self) -> str:
"""
:return: the key by which the backend is identified in the registry and in configuration
"""
return self._key
def is_lsp(self):
return self.get_key() == BuiltinLanguageBackend.LSP.value
def is_jetbrains(self):
return self.get_key() == BuiltinLanguageBackend.JETBRAINS.value
@property
def name(self):
return self.get_key()
@abstractmethod
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
"""
:return: mapping from LSP tool classes to replacement tool classes (functional replacements)
"""
@abstractmethod
def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]:
"""
Creates backend-specific facades for the given agent and API scope.
:param agent: the agent
:param api_scope: the API scope defining active facade methods
:return: the list of facades to be used by the agent for this backend
"""
@abstractmethod
def init_active_project(self, agent: "SerenaAgent") -> None:
"""
Initialises the backend for the given agent's newly activated project.
:param agent: the agent, which has just set a new active project
"""
@abstractmethod
def shutdown_active_project(self, project: "Project", timeout: float) -> None:
"""
Cleans up, freeing resources, after a project has been deactivated.
:param project: the project
:param timeout: the timeout, in seconds, after which to give up on graceful shutdown
"""
def get_project_activation_statement(self, project: "Project") -> str:
"""
:return: a statement to add to the project activation message
"""
return ""
def get_config_overview_statement(self, project: "Project") -> str:
"""
:return: a statement to add to the project configuration overview
"""
return ""
@abstractmethod
def create_code_editor(self, project: "Project") -> "CodeEditor":
pass
@abstractmethod
def is_source_file(self, abs_path: str, project: "Project") -> bool:
"""
Determines whether the given absolute path corresponds to a source file that can (potentially) be processed/understood by the backend.
:param abs_path: the absolute path to an existing file
:param project: the project in which the file is located
:return: True if the file is a source file for this backend (or the backend does not specifically make distinctions),
False otherwise
"""
@abstractmethod
def is_external_path(self, relative_path: str) -> bool:
"""
Determines whether the given relative path corresponds to a file that is external to the project (e.g. a dependency file).
Virtually all of Serena's interfaces use `relative_path` (relative to the project root) to refer to files, but some backends
may need to support project-external files. In this case, the external path should be encoded in the `relative_path` parameter
(e.g. "<ext:/path/to/whatever>") rather than this being an actual relative path that points outside the project root.
Therefore, information about the project in question is deliberately not provided to this method.
:param relative_path: the relative path to a file within the project or an encoded external path.
The path can be assumed to have been provided by the backend itself.
:return: whether the file is considered external to the project by this backend
"""
@abstractmethod
def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy:
"""
Creates a file proxy for the given relative path in the given project.
:param relative_path: the relative path to a file within the project or an encoded external path.
:param project: the project
:return: a file proxy for the given file
"""
class BuiltinLanguageBackend(Enum):
LSP = "LSP"
"""
Use the language server protocol (LSP), spawning freely available language servers
via the SolidLSP library that is part of Serena
"""
JETBRAINS = "JetBrains"
"""
Use the Serena plugin in your JetBrains IDE.
(requires the plugin to be installed and the project being worked on to be open in your IDE)
"""
@staticmethod
def from_str(backend_str: str) -> "BuiltinLanguageBackend":
for backend in BuiltinLanguageBackend:
if backend.value.lower() == backend_str.lower():
return backend
raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in BuiltinLanguageBackend]}")
@cache
def get_instance(self) -> LanguageBackend:
if self == BuiltinLanguageBackend.LSP:
from .lsp.lsp_backend import LanguageBackendLSP
return LanguageBackendLSP()
elif self == BuiltinLanguageBackend.JETBRAINS:
from .jetbrains.jetbrains_backend import LanguageBackendJetBrains
return LanguageBackendJetBrains()
else:
raise NotImplementedError
class LanguageBackendRegistry:
"""
Registry of language backends
"""
REGISTRATION_ENTRY_POINT_GROUP = "serena.language_backend_registration"
"""
entry point group for language backend registration functions; each function should call use
`LanguageBackendRegistry.get_instance().register(...)` to register a backend
"""
_instance = None
_instance_lock = threading.Lock()
@classmethod
def get_instance(cls):
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls(True)
cls._discover_backends_from_entry_points()
return cls._instance
def __init__(self, _singleton: bool):
if not _singleton:
raise RuntimeError("LanguageServerRegistry is a singleton. Use get_instance() to access it.")
self._registered_backends: dict[str, LanguageBackend] = {}
# auto-register built-in language backends
for builtin_backend in BuiltinLanguageBackend:
self._registered_backends[builtin_backend.value] = builtin_backend.get_instance()
@classmethod
def _discover_backends_from_entry_points(cls) -> None:
"""
Discover and execute language server adapter registration functions from entry points.
"""
log.debug("Discovering language backend registration entry points ...")
try:
entry_points = importlib.metadata.entry_points(group=cls.REGISTRATION_ENTRY_POINT_GROUP)
except Exception as error:
log.exception("Failed to discover language server registration entry points: %s", error)
return
def get_distribution_name(ep: importlib.metadata.EntryPoint) -> str:
distribution = getattr(ep, "dist", None)
if distribution is None:
return "unknown distribution"
return distribution.name or "unknown distribution"
log.debug("Found %d language server registration entry points", len(entry_points))
for entry_point in entry_points:
try:
registration = entry_point.load()
if not callable(registration):
raise TypeError("Entry point must resolve to a callable registration function")
registration()
except Exception as error:
log.exception(
"Failed to load language backend entry point '%s' from %s: %s",
entry_point.name,
get_distribution_name(entry_point),
error,
)
def resolve(self, key: str) -> LanguageBackend:
if key in self._registered_backends:
return self._registered_backends[key]
raise ValueError(f"Unknown language backend key: '{key}'; Valid keys: {self.get_keys()}")
def register(self, backend: LanguageBackend, allow_override: bool = False) -> None:
"""
:param backend: the backend to register
:param allow_override: whether to allow overriding an existing registration with the same key
"""
key = backend.get_key()
log.info("Registering language backend: %s (class=%s)", key, backend.__class__.__name__)
if backend.get_key() in self._registered_backends and not allow_override:
raise ValueError(f"Language backend already registered: {key}")
self._registered_backends[key] = backend
def get_keys(self) -> list[str]:
"""
:return: the sorted list of all registered string keys
"""
return sorted(self._registered_backends.keys())
+80
View File
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import TYPE_CHECKING
from overrides import override
from sensai.util.logging import LogTime
from serena.language_backend import BuiltinLanguageBackend, LanguageBackend
from serena.util.file_proxy import FileProxy, LocalProjectFileProxy
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import LanguageServerCodeEditor
from serena.project import Project
from serena.repl.facade import ApiScope, Facade
from serena.tools import Tool
log = logging.getLogger(__name__)
class LanguageBackendLSP(LanguageBackend):
def __init__(self):
super().__init__(BuiltinLanguageBackend.LSP.value)
@override
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
return {}
@override
def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]:
from ..repl.api.lsp_api import LspApi
from ..repl.facade import Facade
return [Facade.from_api(LspApi(agent), api_scope)]
@override
def init_active_project(self, agent: "SerenaAgent") -> None:
with LogTime("Language server initialization", logger=log):
agent.reset_language_server_manager()
@override
def shutdown_active_project(self, project: "Project", timeout: float) -> None:
# nothing to do; the language server manager is already shut down by the project itself
pass
@override
def get_project_activation_statement(self, project: "Project") -> str:
language_servers_str = ", ".join([ls.get_key() for ls in project.project_config.language_servers])
return f"Active language servers: {language_servers_str}.\n"
@override
def get_config_overview_statement(self, project: "Project") -> str:
return f"Language server status: {project.get_language_server_manager_status()}\n"
@override
def create_code_editor(self, project: "Project") -> "LanguageServerCodeEditor":
from serena.code_editor import LanguageServerCodeEditor
from serena.symbol import LanguageServerSymbolRetriever
symbol_retriever = LanguageServerSymbolRetriever(project)
return LanguageServerCodeEditor(symbol_retriever)
@override
def is_source_file(self, abs_path: str, project: "Project") -> bool:
is_file_in_supported_languages = False
for language in project.project_config.language_servers:
fn_matcher = language.get_source_fn_matcher()
if fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_languages = True
break
return is_file_in_supported_languages
@override
def is_external_path(self, relative_path: str) -> bool:
# LSP backend currently uses only true project-relative paths
return False
@override
def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy:
return LocalProjectFileProxy(relative_path, project)
@@ -3,12 +3,14 @@
import json import json
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Optional, Self
from serena.util.text_utils import TextOutputUtils
from solidlsp import ls_types from solidlsp import ls_types
from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity
if TYPE_CHECKING: if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.symbol import LanguageServerSymbolRetriever from serena.symbol import LanguageServerSymbolRetriever
@@ -203,3 +205,55 @@ class DiagnosticsDiff:
def get_grouped_diagnostics(self) -> GroupedDiagnostics: def get_grouped_diagnostics(self) -> GroupedDiagnostics:
return self._grouped_diagnostics return self._grouped_diagnostics
class DiagnosticsContext:
ENABLE_DIAGNOSTICS_DEFAULT: bool = False
"""
Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class.
The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual
edits often intentionally introduce diagnostics (e.g. function signature mismatches or even syntax errors) that
are then resolved in subsequent edits.
"""
DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]"
def __init__(self, agent: "SerenaAgent", *edited_relative_paths: str, enable: bool = ENABLE_DIAGNOSTICS_DEFAULT) -> None:
self._is_diagnostics_enabled = enable and agent.get_language_backend()
self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths]
self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None
self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None
if self._is_diagnostics_enabled:
from serena.symbol import LanguageServerSymbolRetriever # local import to avoid a circular dependency
self._symbol_retriever = LanguageServerSymbolRetriever(agent.get_active_project_or_raise())
self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, self._symbol_retriever)
def __enter__(self) -> Self:
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def format_result(
self,
base_result: str,
) -> str:
if not self._is_diagnostics_enabled:
return base_result
if self._before_edit_diagnostics_snapshot is None:
return base_result
assert self._symbol_retriever is not None
diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever)
grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict()
if not grouped_diagnostics:
return base_result
else:
result_dict = {
"result": base_result,
self.DIAGNOSTICS_KEY: grouped_diagnostics,
}
return TextOutputUtils.to_json(result_dict)
+17 -25
View File
@@ -11,23 +11,23 @@ from dataclasses import dataclass
from typing import Any, Literal, cast from typing import Any, Literal, cast
import docstring_parser import docstring_parser
from mcp.server.fastmcp import server from mcp.server.mcpserver import server
from mcp.server.fastmcp.exceptions import ToolError from mcp.server.mcpserver.context import LifespanContextT, RequestT
from mcp.server.fastmcp.server import Context, FastMCP, Settings from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.fastmcp.tools.base import Tool as FastMCPTool from mcp.server.mcpserver.server import Context
from mcp.server.session import ServerSessionT from mcp.server.mcpserver.server import MCPServer as FastMCP
from mcp.shared.context import LifespanContextT, RequestT from mcp.server.mcpserver.tools.base import Tool as FastMCPTool
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
from pydantic_settings import SettingsConfigDict
from sensai.util import logging from sensai.util import logging
from serena import __version__ from serena import __version__ as serena_version_str
from serena.agent import ( from serena.agent import (
SerenaAgent, SerenaAgent,
) )
from serena.config.context_mode import SerenaAgentContext from serena.config.context_mode import SerenaAgentContext
from serena.config.serena_config import LanguageBackend, ModeSelectionDefinition, SerenaConfig from serena.config.serena_config import AgentInterface, ModeSelectionDefinition, SerenaConfig
from serena.constants import DEFAULT_CONTEXT, SERENA_LOG_FORMAT from serena.constants import DEFAULT_CONTEXT, SERENA_LOG_FORMAT
from serena.language_backend import LanguageBackend
from serena.tools import Tool, ToolCallError from serena.tools import Tool, ToolCallError
from serena.util.exception import show_fatal_exception_safe from serena.util.exception import show_fatal_exception_safe
from serena.util.logging import MemoryLogHandler from serena.util.logging import MemoryLogHandler
@@ -109,8 +109,8 @@ class SerenaFastMCPTool(FastMCPTool):
can_edit = tool.can_edit() can_edit = tool.can_edit()
annotations = ToolAnnotations( annotations = ToolAnnotations(
title=tool_title, title=tool_title,
readOnlyHint=not can_edit, read_only_hint=not can_edit,
destructiveHint=can_edit, destructive_hint=can_edit,
) )
super().__init__( super().__init__(
@@ -132,7 +132,7 @@ class SerenaFastMCPTool(FastMCPTool):
async def run( async def run(
self, self,
arguments: dict[str, Any], arguments: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, context: Context[LifespanContextT, RequestT],
convert_result: bool = False, convert_result: bool = False,
) -> Any: ) -> Any:
# apply parameter aliases # apply parameter aliases
@@ -322,10 +322,9 @@ class SerenaMCPFactory:
def create_mcp_server( def create_mcp_server(
self, self,
host: str = "127.0.0.1",
port: int = 8000,
mode_selection_def: ModeSelectionDefinition | None = None, mode_selection_def: ModeSelectionDefinition | None = None,
language_backend: LanguageBackend | None = None, language_backend: LanguageBackend | None = None,
agent_interface: AgentInterface | None = None,
enable_web_dashboard: bool | None = None, enable_web_dashboard: bool | None = None,
enable_gui_log_window: bool | None = None, enable_gui_log_window: bool | None = None,
open_web_dashboard: bool | None = None, open_web_dashboard: bool | None = None,
@@ -337,10 +336,9 @@ class SerenaMCPFactory:
""" """
Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination. Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination.
:param host: The host to bind to
:param port: The port to bind to
:param mode_selection_def: the mode selection definition to apply :param mode_selection_def: the mode selection definition to apply
:param language_backend: the language backend to use, overriding the configuration setting. :param language_backend: the language backend to use, overriding the configuration setting.
:param agent_interface: the agent interface to use, overriding the configuration setting.
:param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration.
:param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then.
If not specified, will take the value from the serena configuration. If not specified, will take the value from the serena configuration.
@@ -371,6 +369,8 @@ class SerenaMCPFactory:
config.tool_timeout = tool_timeout config.tool_timeout = tool_timeout
if language_backend is not None: if language_backend is not None:
config.language_backend = language_backend config.language_backend = language_backend
if agent_interface is not None:
config.agent_interface = agent_interface
self.agent = self._create_serena_agent(config, modes=mode_selection_def, project_activation_error=project_activation_error) self.agent = self._create_serena_agent(config, modes=mode_selection_def, project_activation_error=project_activation_error)
@@ -378,23 +378,15 @@ class SerenaMCPFactory:
show_fatal_exception_safe(e) show_fatal_exception_safe(e)
raise raise
# Override model_config to disable the use of `.env` files for reading settings, because user projects are likely to contain
# `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings;
# retain only FASTMCP_ prefix for already set environment variables.
Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_")
instructions = self._get_initial_instructions() instructions = self._get_initial_instructions()
log.info("MCP server initial instructions:\n%s", instructions) log.info("MCP server initial instructions:\n%s", instructions)
mcp = FastMCP( mcp = FastMCP(
name="Serena", name="Serena",
version=serena_version_str,
lifespan=self.server_lifespan, lifespan=self.server_lifespan,
website_url="https://oraios.github.io/serena", website_url="https://oraios.github.io/serena",
host=host,
port=port,
instructions=instructions, instructions=instructions,
) )
# FastMCP currently falls back to the installed mcp SDK version when no version is set.
# Set the low-level server value explicitly so MCP clients identify Serena correctly.
mcp._mcp_server.version = __version__
return mcp return mcp
@asynccontextmanager @asynccontextmanager
+16 -5
View File
@@ -330,6 +330,7 @@ class MemoryManager:
new_name = self._sanitize_name(new_name) new_name = self._sanitize_name(new_name)
self._check_not_ignored(old_name) self._check_not_ignored(old_name)
self._check_not_ignored(new_name) self._check_not_ignored(new_name)
self._check_write_access(old_name, is_tool_context)
self._check_write_access(new_name, is_tool_context) self._check_write_access(new_name, is_tool_context)
old_path = self.get_memory_file_path(old_name) old_path = self.get_memory_file_path(old_name)
@@ -347,22 +348,32 @@ class MemoryManager:
def rename_memory_and_propagate_references(self, old_name: str, new_name: str, is_tool_context: bool) -> tuple[str, int]: def rename_memory_and_propagate_references(self, old_name: str, new_name: str, is_tool_context: bool) -> tuple[str, int]:
""" """
Renames a memory and updates every ``mem:OLD_NAME`` reference across all memories. Renames a memory and updates every ``mem:OLD_NAME`` reference in the memories which
accept writes in the given context.
Memories whose content does not contain a reference to ``old_name`` are left Memories whose content does not contain a reference to ``old_name`` are left
untouched (no spurious mtime changes). Memories that do are rewritten via untouched (no spurious mtime changes); those that do are rewritten via
:meth:`save_memory`. :meth:`save_memory`. References in a memory which does not accept writes (a read-only
memory in a tool context) are not affected and remain reported as stale by
:meth:`validate_referential_integrity`.
:param old_name: the current memory name (the source of the rename) :param old_name: the current memory name (the source of the rename)
:param new_name: the target memory name :param new_name: the target memory name
:param is_tool_context: forwarded to :meth:`save_memory` for read-only enforcement :param is_tool_context: forwarded to :meth:`save_memory` for read-only enforcement
:return: a tuple of (rename message returned by :meth:`move_memory`, total number of :return: a tuple of (rename message returned by :meth:`move_memory`, total number of
``mem:`` reference occurrences rewritten across all memories). ``mem:`` reference occurrences rewritten in those memories).
""" """
renaming_message = self.move_memory(old_name, new_name, is_tool_context=is_tool_context) renaming_message = self.move_memory(old_name, new_name, is_tool_context=is_tool_context)
# propagate the reference, enumerating after the move such that the renamed memory
# itself is covered; the read-only memories are excluded in a tool context because
# writing to one would raise after the move was already applied, leaving the memory
# graph half-updated
memories_list = self.list_memories()
target_names = sorted(memories_list.memories) if is_tool_context else memories_list.get_full_list()
total_updates = 0 total_updates = 0
for memory_name in self.list_memories().get_full_list(): for memory_name in target_names:
content = self.load_memory(memory_name) content = self.load_memory(memory_name)
updated_content, n_replacements = self.rename_references_to_memory(content, old_name, new_name) updated_content, n_replacements = self.rename_references_to_memory(content, old_name, new_name)
if n_replacements > 0: if n_replacements > 0:
+38 -29
View File
@@ -12,11 +12,11 @@ from sensai.util.logging import LogTime
from sensai.util.string import TextBuilder, ToStringMixin from sensai.util.string import TextBuilder, ToStringMixin
from serena.config.serena_config import ( from serena.config.serena_config import (
LanguageBackend,
ProjectConfig, ProjectConfig,
ProjectConfigAutoGenerationMode, ProjectConfigAutoGenerationMode,
SerenaConfig, SerenaConfig,
) )
from serena.language_backend import LanguageBackend
from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.ls_manager import LanguageServerFactory, LanguageServerManager
from serena.memories.memory_manager import MemoryManager from serena.memories.memory_manager import MemoryManager
from serena.util.file_proxy import FileCollection, FileProxy from serena.util.file_proxy import FileCollection, FileProxy
@@ -128,7 +128,7 @@ class Project(ToStringMixin):
@property @property
def language_backend(self) -> LanguageBackend: def language_backend(self) -> LanguageBackend:
# The backend configuration is fundamentally owned by the agent, so it takes # The backend configuration is fundamentally owned by the agent, so it takes
# precedence. (Note: The agent does not necessary honour the project's choice, # precedence. (Note: The agent does not necessarily honour the project's choice,
# as it may be invalid.) # as it may be invalid.)
if self._agent is not None: if self._agent is not None:
return self._agent.get_language_backend() return self._agent.get_language_backend()
@@ -212,7 +212,9 @@ class Project(ToStringMixin):
) )
return self.__ignored_patterns return self.__ignored_patterns
def _is_ignored_relative_path(self, relative_path: str | Path, ignore_non_source_files: bool = True) -> bool: def _is_ignored_relative_path(
self, relative_path: str | Path, ignore_non_source_files: bool = True, is_file: bool | None = None
) -> bool:
""" """
Determine whether a path should be ignored based on file type and ignore patterns. Determine whether a path should be ignored based on file type and ignore patterns.
Returns False for non-existent paths since they cannot be matched by ignore patterns. Returns False for non-existent paths since they cannot be matched by ignore patterns.
@@ -220,6 +222,7 @@ class Project(ToStringMixin):
:param relative_path: Relative path to check :param relative_path: Relative path to check
:param ignore_non_source_files: whether files that are not source files (according to the file masks :param ignore_non_source_files: whether files that are not source files (according to the file masks
determined by the project's programming language) shall be ignored determined by the project's programming language) shall be ignored
:param is_file: whether the path exists and is a file, for callers that already know
:return: whether the path should be ignored :return: whether the path should be ignored
""" """
@@ -230,24 +233,19 @@ class Project(ToStringMixin):
return False return False
abs_path = os.path.join(self.project_root, relative_path) abs_path = os.path.join(self.project_root, relative_path)
if not os.path.exists(abs_path): if is_file is None:
log.debug(f"Path {abs_path} does not exist, skipping ignore check") if not os.path.exists(abs_path):
return False log.debug(f"Path {abs_path} does not exist, skipping ignore check")
return False
# check code file restriction (depending on backend) # check code file restriction (depending on backend)
if ignore_non_source_files: if ignore_non_source_files:
# apply restriction only for LSP backend, which enumerates known languages if is_file is None:
# and therefore can determine whether a file is a source file or not is_file = os.path.isfile(abs_path)
if self.language_backend.is_lsp(): if is_file:
if os.path.isfile(abs_path): # non-source files are ignored
is_file_in_supported_language = False if not self.language_backend.is_source_file(abs_path, self):
for language in self.project_config.language_servers: return True
fn_matcher = language.get_source_fn_matcher()
if fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_language = True
break
if not is_file_in_supported_language:
return True
# Create normalized path for consistent handling # Create normalized path for consistent handling
rel_path = Path(relative_path) rel_path = Path(relative_path)
@@ -256,15 +254,18 @@ class Project(ToStringMixin):
if len(rel_path.parts) > 0 and ".git" in rel_path.parts: if len(rel_path.parts) > 0 and ".git" in rel_path.parts:
return True return True
return match_path(str(relative_path), self._ignore_spec, root_path=self.project_root) is_dir = None if is_file is None else not is_file
return match_path(str(relative_path), self._ignore_spec, root_path=self.project_root, is_dir=is_dir)
def is_ignored_path(self, path: str | Path, ignore_non_source_files: bool = False) -> bool: def is_ignored_path(self, path: str | Path, ignore_non_source_files: bool = False, is_file: bool | None = None) -> bool:
""" """
Checks whether the given path is ignored Checks whether the given path is ignored
:param path: the path to check, can be absolute or relative :param path: the path to check, can be absolute or relative
:param ignore_non_source_files: whether to ignore files that are not source files :param ignore_non_source_files: whether to ignore files that are not source files
(according to the file masks determined by the project's programming language) (according to the file masks determined by the project's programming language)
:param is_file: whether the path exists and is a file, for callers that already know;
see :meth:`_is_ignored_relative_path`. `None` determines it from the filesystem.
""" """
path = Path(path) path = Path(path)
if path.is_absolute(): if path.is_absolute():
@@ -278,7 +279,7 @@ class Project(ToStringMixin):
else: else:
relative_path = path relative_path = path
return self._is_ignored_relative_path(str(relative_path), ignore_non_source_files=ignore_non_source_files) return self._is_ignored_relative_path(str(relative_path), ignore_non_source_files=ignore_non_source_files, is_file=is_file)
def get_is_ignored_path_fn(self, base_path: str, skip_ignored_paths: bool) -> Callable[[str], bool]: def get_is_ignored_path_fn(self, base_path: str, skip_ignored_paths: bool) -> Callable[[str], bool]:
""" """
@@ -336,7 +337,7 @@ class Project(ToStringMixin):
:param relative_path: the path to validate, relative to the project root :param relative_path: the path to validate, relative to the project root
:param require_not_ignored: if True, the path must not be ignored according to the project's ignore settings :param require_not_ignored: if True, the path must not be ignored according to the project's ignore settings
""" """
if FileProxy.is_external_path(relative_path): if FileProxy.is_external_path(relative_path, self):
return return
if not self.is_path_in_project(relative_path): if not self.is_path_in_project(relative_path):
@@ -358,15 +359,17 @@ class Project(ToStringMixin):
if os.path.isfile(start_path): if os.path.isfile(start_path):
return [relative_path] return [relative_path]
else: else:
# os.walk hands back directories and files separately, so `is_file` is already known here and
# does not have to be re-derived from the filesystem for every one of them.
for root, dirs, files in os.walk(start_path, followlinks=True): for root, dirs, files in os.walk(start_path, followlinks=True):
# prevent recursion into ignored directories # prevent recursion into ignored directories
dirs[:] = [d for d in dirs if not self.is_ignored_path(os.path.join(root, d))] dirs[:] = [d for d in dirs if not self.is_ignored_path(os.path.join(root, d), is_file=False)]
# collect non-ignored files # collect non-ignored files
for file in files: for file in files:
abs_file_path = os.path.join(root, file) abs_file_path = os.path.join(root, file)
try: try:
if not self.is_ignored_path(abs_file_path, ignore_non_source_files=True): if not self.is_ignored_path(abs_file_path, ignore_non_source_files=True, is_file=True):
try: try:
rel_file_path = os.path.relpath(abs_file_path, start=self.project_root) rel_file_path = os.path.relpath(abs_file_path, start=self.project_root)
except Exception: except Exception:
@@ -383,7 +386,7 @@ class Project(ToStringMixin):
) )
return rel_file_paths return rel_file_paths
def _create_file_collection(self, relative_path: str, *, code_files_only: bool, skip_ignored_files: bool) -> FileCollection: def create_file_collection(self, relative_path: str, *, code_files_only: bool, skip_ignored_files: bool) -> FileCollection:
""" """
Creates the file collection for the given relative path. Creates the file collection for the given relative path.
@@ -392,7 +395,7 @@ class Project(ToStringMixin):
:param skip_ignored_files: whether to skip ignored files; has no effect if `code_files_only` is True :param skip_ignored_files: whether to skip ignored files; has no effect if `code_files_only` is True
:return: :return:
""" """
if FileProxy.is_external_path(relative_path): if FileProxy.is_external_path(relative_path, self):
# single external path: create appropriate proxy # single external path: create appropriate proxy
file_collection = FileCollection([FileProxy.from_project_relative_path(self, relative_path)]) file_collection = FileCollection([FileProxy.from_project_relative_path(self, relative_path)])
else: else:
@@ -446,9 +449,7 @@ class Project(ToStringMixin):
:param skip_ignored_files: whether to skip ignored files; has no effect if `code_files_only` is True :param skip_ignored_files: whether to skip ignored files; has no effect if `code_files_only` is True
:return: list of matches :return: list of matches
""" """
file_collection = self._create_file_collection( file_collection = self.create_file_collection(relative_path, code_files_only=code_files_only, skip_ignored_files=skip_ignored_files)
relative_path, code_files_only=code_files_only, skip_ignored_files=skip_ignored_files
)
return search_files( return search_files(
file_collection, file_collection,
pattern, pattern,
@@ -623,6 +624,14 @@ class Project(ToStringMixin):
return 0 return 0
def shutdown(self, timeout: float = 2.0) -> None: def shutdown(self, timeout: float = 2.0) -> None:
"""
Shuts down the project, calling the language backend-specific shutdown of the active project.
:param timeout: the timeout, in seconds
"""
# clean up internal resources
if self.language_server_manager is not None: if self.language_server_manager is not None:
self.language_server_manager.stop_all(save_cache=True, timeout=timeout) self.language_server_manager.stop_all(save_cache=True, timeout=timeout)
self.language_server_manager = None self.language_server_manager = None
# trigger additional backend-specific shutdown
self.language_backend.shutdown_active_project(self, timeout=timeout)
+87 -9
View File
@@ -2,16 +2,19 @@
import json import json
import logging import logging
import pickle
import secrets
import threading import threading
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
import requests as requests_lib import requests as requests_lib
from flask import Flask, request from flask import Flask, Response, abort, request
from pydantic import BaseModel from pydantic import BaseModel
from sensai.util.logging import LogTime from sensai.util.logging import LogTime
from serena.config.serena_config import LanguageBackend, SerenaConfig from serena.config.serena_config import SerenaConfig
from serena.constants import SerenaPorts from serena.constants import SerenaPorts
from serena.language_backend import BuiltinLanguageBackend
if TYPE_CHECKING: if TYPE_CHECKING:
from serena.project import Project from serena.project import Project
@@ -33,6 +36,19 @@ class QueryProjectRequest(BaseModel):
tool_params_json: str tool_params_json: str
class CallFacadeMethodRequest(BaseModel):
"""
Request model for the /call_facade_method endpoint: the execution of a REPL facade method
in the context of a project.
"""
project_name: str
facade_name: str
method_name: str
args: list[Any]
kwargs: dict[str, Any]
class ProjectServer: class ProjectServer:
""" """
A lightweight Flask server that exposes a SerenaAgent's project querying A lightweight Flask server that exposes a SerenaAgent's project querying
@@ -58,7 +74,7 @@ class ProjectServer:
port = self.PORT port = self.PORT
serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides()
serena_config.language_backend = LanguageBackend.LSP serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
self._agent = SerenaAgent(serena_config=serena_config) self._agent = SerenaAgent(serena_config=serena_config)
self._loaded_projects_by_root: dict[str, "Project"] = {} self._loaded_projects_by_root: dict[str, "Project"] = {}
@@ -76,7 +92,22 @@ class ProjectServer:
self._setup_routes() self._setup_routes()
def get_serena_config(self) -> SerenaConfig:
return self._agent.serena_config
def get_auth_secret(self) -> str:
"""Returns the authentication secret used by the server."""
return self._agent.serena_config.auth_secret
def _setup_routes(self) -> None: def _setup_routes(self) -> None:
@self._app.before_request
def authenticate() -> None:
# authenticate every request before parsing input or accessing projects
secret = self.get_auth_secret()
provided = request.headers.get("Authorization", "")
if not secret or not secrets.compare_digest(provided.encode("utf-8"), f"Bearer {secret}".encode()):
abort(401)
@self._app.route("/heartbeat", methods=["GET"]) @self._app.route("/heartbeat", methods=["GET"])
def heartbeat() -> dict[str, str]: def heartbeat() -> dict[str, str]:
return {"status": "alive"} return {"status": "alive"}
@@ -86,6 +117,18 @@ class ProjectServer:
query_request = QueryProjectRequest.model_validate(request.get_json()) query_request = QueryProjectRequest.model_validate(request.get_json())
return self._query_project(query_request) return self._query_project(query_request)
@self._app.route("/call_facade_method", methods=["POST"])
def call_facade_method() -> Response:
call_request = CallFacadeMethodRequest.model_validate(request.get_json())
try:
result = self._call_facade_method(call_request)
except Exception as e:
# report the error to the client (which raises it in the REPL) instead of a generic server error page
log.warning("Facade method call failed: %s", e)
return Response(f"{type(e).__name__}: {e}", status=400, mimetype="text/plain")
# NOTE: the result is pickled; the client (a Serena instance on the same machine) unpickles it
return Response(pickle.dumps(result), mimetype="application/octet-stream")
def _get_project(self, project_root_or_name: str) -> "Project": def _get_project(self, project_root_or_name: str) -> "Project":
"""Gets the project with the given name, loading it if necessary.""" """Gets the project with the given name, loading it if necessary."""
serena_config = self._agent.serena_config serena_config = self._agent.serena_config
@@ -136,6 +179,17 @@ class ProjectServer:
params = json.loads(req.tool_params_json) params = json.loads(req.tool_params_json)
return tool.apply_ex(**params) return tool.apply_ex(**params)
def _call_facade_method(self, req: CallFacadeMethodRequest) -> Any:
"""
Handles a /call_facade_method request by executing the facade method on the agent's REPL facades in the
context of the specified project (see `_query_project` regarding the lock).
"""
project = self._get_project(req.project_name)
with self._active_project_lock, self._agent.active_project_context(project):
facade = self._agent.get_repl().entrypoint.get_facade_(req.facade_name)
method = facade.get_method(req.method_name)
return self._agent.execute_task(lambda: method(*req.args, **req.kwargs))
def run(self) -> None: def run(self) -> None:
""" """
Run the server on the given host and port. Run the server on the given host and port.
@@ -158,18 +212,23 @@ class ProjectServerClient:
:class:`ConnectionError` is raised. :class:`ConnectionError` is raised.
""" """
def __init__(self, host: str = "127.0.0.1", port: int = ProjectServer.PORT, timeout: int = 300) -> None: def __init__(self, serena_config: SerenaConfig, host: str = "127.0.0.1", port: int | None = None) -> None:
""" """
:param host: the host address of the project server. :param host: the host address of the project server.
:param port: the port of the project server. :param port: the port of the project server; if None, use default.
:param auth_secret: the shared authentication secret; defaults to the secret in Serena's configuration.
:raises ConnectionError: if the project server is not reachable. :raises ConnectionError: if the project server is not reachable.
""" """
if port is None:
port = ProjectServer.PORT
self._base_url = f"http://{host}:{port}" self._base_url = f"http://{host}:{port}"
self._timeout = timeout self._timeout = serena_config.tool_timeout - 1
auth_secret = serena_config.auth_secret
self._headers = {"Authorization": f"Bearer {auth_secret}"}
# verify that the server is running # verify that the server is running
try: try:
response = requests_lib.get(f"{self._base_url}/heartbeat", timeout=5) response = requests_lib.get(f"{self._base_url}/heartbeat", headers=self._headers, timeout=5)
response.raise_for_status() response.raise_for_status()
except requests_lib.ConnectionError: except requests_lib.ConnectionError:
raise ConnectionError(f"ProjectServer is not reachable at {self._base_url}. Make sure the server is running.") raise ConnectionError(f"ProjectServer is not reachable at {self._base_url}. Make sure the server is running.")
@@ -194,6 +253,25 @@ class ProjectServerClient:
tool_params_json=tool_params_json, tool_params_json=tool_params_json,
).model_dump() ).model_dump()
response = requests_lib.post(f"{self._base_url}/query_project", json=payload, timeout=self._timeout) response = requests_lib.post(f"{self._base_url}/query_project", json=payload, headers=self._headers, timeout=self._timeout)
response.raise_for_status() response.raise_for_status()
return response.text return response.text
def call_facade_method(self, project_name: str, facade_name: str, method_name: str, args: list[Any], kwargs: dict[str, Any]) -> Any:
"""
Executes a (read-only) REPL facade method in the context of a project.
:param project_name: the name of the project to query
:param facade_name: the facade's name
:param method_name: the method's name
:param args: the positional arguments (JSON-serialisable)
:param kwargs: the keyword arguments (JSON-serialisable)
:return: the method's result, as returned by the server (unpickled; the server is a trusted local process)
"""
payload = CallFacadeMethodRequest(
project_name=project_name, facade_name=facade_name, method_name=method_name, args=args, kwargs=kwargs
).model_dump()
response = requests_lib.post(f"{self._base_url}/call_facade_method", json=payload, headers=self._headers, timeout=self._timeout)
if not response.ok:
raise ValueError(f"Project server error ({response.status_code}): {response.text[:2000]}")
return pickle.loads(response.content)
+40
View File
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of operations concerning Serena's configuration and session state.
"""
from typing import TYPE_CHECKING
from serena.tools import GetCurrentConfigTool, OpenDashboardTool
from ..facade import FacadeApi, facade_method
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class ConfigApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(agent, name="cfg", description="Serena's configuration and session state (incl. the dashboard)")
@facade_method(corresponding_tool=GetCurrentConfigTool)
def get_current_config(self) -> str:
"""
Provides the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
:return: the configuration overview
"""
return self._agent.get_current_config_overview()
@facade_method(corresponding_tool=OpenDashboardTool)
def open_dashboard(self) -> str:
"""
Opens the Serena web dashboard in the default web browser.
The dashboard provides logs, session information, and tool usage statistics.
:return: a message indicating whether the dashboard could be opened
"""
if self._agent.open_dashboard():
return f"Serena web dashboard has been opened in the user's default web browser: {self._agent.get_dashboard_url()}"
else:
return f"Serena web dashboard could not be opened automatically; tell the user to open it via {self._agent.get_dashboard_url()}"
+302
View File
@@ -0,0 +1,302 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of editing operations, which are independent of the language backend.
"""
from typing import TYPE_CHECKING, Literal
from serena.code_editor import EditedFileContext
from serena.tools import (
DeleteLinesTool,
InsertAfterSymbolTool,
InsertAtLineTool,
InsertBeforeSymbolTool,
ReplaceContentTool,
ReplaceInFilesTool,
ReplaceLinesTool,
ReplaceSymbolBodyTool,
)
from serena.util.text_utils import ContentReplacer, MultiFileReplacement, ReplacementOccurrence, ReplacementRejectedError
from ..facade import SUCCESS_RESULT, FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class ReplacementPreview(RepresentableViaRenderer):
"""
The prospective changes of a multi-file replacement (nothing has been modified).
Each entry of `occurrences` (`ReplacementOccurrence`) has an `occurrence_id` (to be passed to `replace_in_files`
in order to apply exactly that occurrence), `relative_path`, `start_line`, `end_line`, `matched_text`, `replacement`
and `is_ambiguous`.
"""
def __init__(self, replacement: MultiFileReplacement, renderer: "ReplacementPreviewRenderer"):
"""
:param replacement: the replacement
:param renderer: the renderer to use for representing the preview
"""
super().__init__(renderer)
self.replacement_ = replacement
@property
def occurrences(self) -> list[ReplacementOccurrence]:
return self.replacement_.occurrences
@property
def affected_files(self) -> list[str]:
return self.replacement_.affected_files
class ReplacementPreviewRenderer(Renderer[ReplacementPreview]):
"""
Renders the listing of prospective changes (minimal line diffs with occurrence ids), subject to the length limit.
"""
def __init__(self, agent: "SerenaAgent", max_answer_chars: int, dry_run: bool):
"""
:param agent: the agent
:param max_answer_chars: the maximum number of characters; -1 for the configured default
:param dry_run: whether the listing is the result of a dry run (adding instructions on how to proceed)
"""
super().__init__(agent, max_answer_chars)
self._dry_run = dry_run
def render(self, obj: ReplacementPreview) -> str:
return obj.replacement_.render_listing(self._get_max_answer_chars(), dry_run=self._dry_run)
class EditApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(
agent,
name="edit",
description="modifying content within existing files (independent of the language backend)",
types=[
ReferencedType(
ReplacementOccurrence,
members=["occurrence_id", "relative_path", "start_line", "end_line", "matched_text", "replacement", "is_ambiguous"],
),
],
)
# file-level operations
@facade_method(can_edit=True, corresponding_tool=ReplaceContentTool)
def replace_content(
self,
relative_path: str,
needle: str,
repl: str,
mode: Literal["literal", "regex"],
allow_multiple_occurrences: bool = False,
) -> str:
r"""
Replaces one or more occurrences of a given pattern in a file with new content.
VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced WITHOUT
quoting them fully: use a needle of the form "beginning.*?end-of-text-to-be-replaced" with
wildcards instead of pasting the exact original text — shorter, cheaper, and you cannot make
mistakes, because an ambiguous match returns an error you can refine, so wildcards are safe.
Prefer regex mode with suitable wildcards for long multi-line replacements; use the
symbol-level editors when replacing a whole method/class.
:param relative_path: the relative path to the file
:param needle: the string or regex pattern to search for.
If `mode` is "literal", this string will be matched exactly.
If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module,
with flags DOTALL and MULTILINE enabled).
:param repl: the replacement string (verbatim).
If mode is "regex", the string can contain backreferences to matched groups in the needle regex,
specified using the syntax $!1, $!2, etc. for groups 1, 2, etc.
:param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted.
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
If false and multiple occurrences are found, an error will be raised
:return: a success message
"""
self._get_project().validate_relative_path(relative_path)
with EditedFileContext(relative_path, self._create_code_editor()) as context:
replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences)
context.set_updated_content(replacer.replace(context.get_original_content(), needle, repl))
return SUCCESS_RESULT
@facade_method(can_edit=True, corresponding_tool=ReplaceInFilesTool)
def replace_in_files(
self,
needle: str,
repl: str,
mode: Literal["literal", "regex"],
relative_path: str = "",
paths_include_glob: str = "",
paths_exclude_glob: str = "",
dry_run: bool = False,
occurrence_ids: list[str] | None = None,
expected_count: int = -1,
max_answer_chars: int = -1,
) -> ReplacementPreview | str:
r"""
Replaces occurrences of a pattern across multiple files in ONE call.
This is the preferred operation for repeated small edits (renames, import swaps, annotation changes,
path prefixes) spanning several files or many places in one file: one call with a SHORT pattern
replaces many single-file replacements with long disambiguating needles.
Recommended protocol whenever there is ANY risk of unintended replacements:
1. Call with dry_run=True: every prospective change is returned as a minimal line diff with an
occurrence id; nothing is modified.
2. Call again with dry_run=False, passing the ids you want in occurrence_ids (omit it to apply
all). You pick the desired replacements from the list - no counting, no needle-crafting.
For clearly unambiguous bulk replacements you may skip the dry run; pass expected_count as a
guard. If the actual number of matches differs, NOTHING is changed and an error containing the
prospective changes is raised, so a failed guard costs one call and gives you the dry-run output to select from.
:param needle: the string (mode "literal") or regular expression (mode "regex"; Python `re`
syntax with DOTALL and MULTILINE) to search for
:param repl: the replacement string. In regex mode, backreferences to matched groups can be
specified as $!1, $!2, etc.
:param mode: either "literal" or "regex", specifying how `needle` is to be interpreted
:param relative_path: only consider this file or directory (default: the whole project)
:param paths_include_glob: optional glob (relative to the project root, e.g. "src/**/*.java")
restricting which files are considered
:param paths_exclude_glob: optional glob of files to exclude; takes precedence over the include glob
:param dry_run: if True, do not modify anything; return the prospective changes with occurrence ids
:param occurrence_ids: optional list of occurrence ids (obtained from a dry run) to which the
replacement is restricted; if any id is unknown or stale, NOTHING is changed. If omitted,
all occurrences are replaced.
:param expected_count: optional guard for calls without occurrence_ids: the number of
occurrences you expect to be replaced. If the actual count differs, nothing is changed and
an error containing the prospective changes is raised. -1 disables the guard.
:return: in a dry run, the prospective changes (`ReplacementPreview`); otherwise a summary of the applied replacements
"""
replacement = MultiFileReplacement(
self._get_project(),
needle,
repl,
mode,
relative_path=relative_path,
paths_include_glob=paths_include_glob,
paths_exclude_glob=paths_exclude_glob,
)
if dry_run:
return ReplacementPreview(replacement, ReplacementPreviewRenderer(self._agent, max_answer_chars, dry_run=True))
# select the occurrences to replace
try:
if occurrence_ids is not None:
occurrences = replacement.select(occurrence_ids)
else:
occurrences = replacement.select_all_guarded(expected_count)
except ReplacementRejectedError as e:
message = str(e)
if e.show_prospective_changes:
preview = ReplacementPreview(replacement, ReplacementPreviewRenderer(self._agent, max_answer_chars, dry_run=False))
message += "\n" + preview.represent()
raise ValueError(message) from e
return replacement.apply(self._create_code_editor(), occurrences).to_display_string()
# line-level operations
@facade_method(optional=True, can_edit=True, corresponding_tool=DeleteLinesTool)
def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> str:
"""
Deletes the given lines in the file.
Requires that the same range of lines was previously read to verify correctness of the operation.
:param relative_path: the relative path to the file
:param start_line: the 0-based index of the first line to be deleted
:param end_line: the 0-based index of the last line to be deleted
:return: a success message
"""
self._create_code_editor().delete_lines(relative_path, start_line, end_line)
return SUCCESS_RESULT
@facade_method(optional=True, can_edit=True, corresponding_tool=ReplaceLinesTool)
def replace_lines(self, relative_path: str, start_line: int, end_line: int, content: str) -> str:
"""
Replaces the given range of lines in the given file.
Requires that the same range of lines was previously read to verify correctness of the operation.
:param relative_path: the relative path to the file
:param start_line: the 0-based index of the first line to be replaced
:param end_line: the 0-based index of the last line to be replaced
:param content: the content to insert
:return: a success message
"""
code_editor = self._create_code_editor()
code_editor.delete_lines(relative_path, start_line, end_line)
code_editor.insert_at_line(relative_path, start_line, self._normalize_inserted_content(content))
return SUCCESS_RESULT
@facade_method(optional=True, can_edit=True, corresponding_tool=InsertAtLineTool)
def insert_at_line(self, relative_path: str, line: int, content: str) -> str:
"""
Inserts the given content at the given line in the file, pushing existing content of the line down.
In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which
symbol you are looking for.
However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body).
:param relative_path: the relative path to the file
:param line: the 0-based index of the line to insert content at
:param content: the content to be inserted
:return: a success message
"""
self._create_code_editor().insert_at_line(relative_path, line, self._normalize_inserted_content(content))
return SUCCESS_RESULT
@staticmethod
def _normalize_inserted_content(content: str) -> str:
return content if content.endswith("\n") else content + "\n"
# symbol-level operations
@facade_method(can_edit=True, corresponding_tool=ReplaceSymbolBodyTool)
def replace_symbol_body(self, name_path: str, relative_path: str, body: str) -> str:
"""
Replaces the body of the given symbol.
IMPORTANT: Only replace symbol bodies if you have previously made a retrieval with include_body=True and thus know what
constitutes the body!
:param name_path: name path of the symbol whose body to replace
:param relative_path: the relative path to the file containing the symbol
:param body: the new symbol body. The symbol body is the definition of a symbol
in the programming language, including e.g. the signature line for functions.
Depending on the language, it may or may not include a preceding docstring or other preceding annotations.
:return: a success message
"""
self._create_code_editor().replace_body(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
@facade_method(can_edit=True, corresponding_tool=InsertAfterSymbolTool)
def insert_after_symbol(self, name_path: str, relative_path: str, body: str) -> str:
"""
Inserts code after a class/method/function definition.
Don't use this to insert after assignments (constants, fields).
:param name_path: name path of the symbol after which to insert content
:param relative_path: the relative path to the file containing the symbol
:param body: the body/content to be inserted. The inserted code shall begin with the next line after
the symbol.
:return: a success message
"""
self._create_code_editor().insert_after_symbol(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
@facade_method(can_edit=True, corresponding_tool=InsertBeforeSymbolTool)
def insert_before_symbol(self, name_path: str, relative_path: str, body: str) -> str:
"""
Inserts the given content before the beginning of the definition of the given symbol (via the symbol's location).
A typical use case is to insert a new class, function, method, field or variable assignment; or
a new import statement before the first symbol in the file.
:param name_path: name path of the symbol before which to insert content
:param relative_path: the relative path to the file containing the symbol
:param body: the body/content to be inserted before the line in which the referenced symbol is defined
:return: a success message
"""
self._create_code_editor().insert_before_symbol(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
+115
View File
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of access to external projects (projects other than the active one).
"""
from types import TracebackType
from typing import TYPE_CHECKING
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager
from serena.tools import ListQueryableProjectsTool, QueryProjectTool
from ..external_project import ExternalProjectExecution
from ..facade import FacadeApi, facade_method
from ..representable import JsonObject, JsonObjectRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class ExternalProjectContextManager:
"""
A context manager (for use in a `with` statement) within which the facades operate on an external project
(read-only): the external project is temporarily activated, and operations requiring language servers are
executed in the project's server. Contexts cannot be nested.
"""
def __init__(self, agent: "SerenaAgent", project_name: str, read_only: bool) -> None:
"""
:param agent: the agent
:param project_name: the name (or root path) of the registered external project
:param read_only: whether the context is read-only
"""
self._agent = agent
self._project_name = project_name
self._active_project_context = None
self._read_only = read_only
def __enter__(self) -> None:
entrypoint = self._agent.get_repl().entrypoint
if entrypoint.get_external_project_() is not None:
raise ValueError("External project contexts cannot be nested")
# temporarily activate the external project
registered_project = self._agent.serena_config.get_registered_project(self._project_name)
if registered_project is None:
raise ValueError(f"Project '{self._project_name}' is not registered and cannot be queried")
project = registered_project.get_project_instance(self._agent.serena_config)
self._active_project_context = self._agent.active_project_context(project)
self._active_project_context.__enter__()
# switch the facades to the external project
entrypoint.set_external_project_(ExternalProjectExecution(registered_project.project_name, self._read_only, self._agent))
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
self._agent.get_repl().entrypoint.set_external_project_(None)
assert self._active_project_context is not None
self._active_project_context.__exit__(exc_type, exc_value, traceback)
class ExternalProjectsApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(agent, name="ext", description="read-only access to external projects (projects other than the active one)")
@facade_method(corresponding_tool=ListQueryableProjectsTool)
def list_projects(self, symbol_access: bool = True) -> JsonObject:
"""
Lists the registered projects which can be queried.
:param symbol_access: whether to list only projects for which symbol-level access is available
:return: the project names mapped to their root directories
"""
registered_projects = self._agent.serena_config.projects
if symbol_access and self._agent.get_language_backend().is_jetbrains():
# only projects with open IDE instances can be queried
matched_clients = JetBrainsPluginClientManager().match_clients(registered_projects)
relevant_projects = [mc.registered_project for mc in matched_clients]
else:
# all projects can be queried (the project server instantiates projects dynamically)
relevant_projects = registered_projects
result = {p.project_name: str(p.project_root) for p in relevant_projects}
return JsonObject(result, JsonObjectRenderer(self._agent, -1))
@facade_method(corresponding_tool=QueryProjectTool)
def read_project_context(self, project_name: str) -> ExternalProjectContextManager:
"""
Provides a context (for use in a `with` statement) within which all facades operate on the given external project
instead of the active one, with read-only access.
Example:
`with s.ext.project_context("other"): result = s.lsp.find_symbol("Foo")`
Results obtained within the context can be used after it (they are self-contained).
:param project_name: the name (or root path) of the project, as listed by `list_projects`
:return: the context manager
"""
return ExternalProjectContextManager(self._agent, project_name, read_only=True)
@facade_method(optional=True)
def project_context(self, project_name: str) -> ExternalProjectContextManager:
"""
Provides a context (for use in a `with` statement) within which all facades operate on the given external project
instead of the active one (read and write operations are possible).
Example:
`with s.ext.project_context("other"): result = s.lsp.find_symbol("Foo")`
Results obtained within the context can be used after it (they are self-contained).
:param project_name: the name (or root path) of the project, as listed by `list_projects`
:return: the context manager
"""
return ExternalProjectContextManager(self._agent, project_name, read_only=False)
+330
View File
@@ -0,0 +1,330 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of operations on the project's files.
"""
import os
from collections import defaultdict
from fnmatch import fnmatch
from pathlib import Path
from typing import TYPE_CHECKING
from serena.tools import CreateTextFileTool, FindFileTool, ListDirTool, ReadFileTool, SearchForPatternTool
from serena.util.file_system import scan_directory
from serena.util.text_utils import MatchedConsecutiveLines
from solidlsp.ls_utils import TextUtils
from ..facade import FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class FileContent(RepresentableViaRenderer):
"""
The content of a file (or of a range of its lines): `text` (the joined lines) and `lines`.
"""
def __init__(self, lines: list[str], renderer: "FileContentRenderer"):
"""
:param lines: the lines (without line breaks)
:param renderer: the renderer to use for representing the content
"""
super().__init__(renderer)
self.lines = lines
lines: list[str]
@property
def text(self) -> str:
return "\n".join(self.lines)
class FileContentRenderer(Renderer[FileContent]):
def render(self, obj: FileContent) -> str:
return self._limit_length(obj.text)
class DirectoryListing(RepresentableViaRenderer):
"""
The entries of a directory: `dirs` and `files` (relative paths).
"""
def __init__(self, dirs: list[str], files: list[str], renderer: "DirectoryListingRenderer"):
"""
:param dirs: the relative paths of the directories
:param files: the relative paths of the files
:param renderer: the renderer to use for representing the listing
"""
super().__init__(renderer)
self.dirs = dirs
self.files = files
dirs: list[str]
files: list[str]
class DirectoryListingRenderer(Renderer[DirectoryListing]):
def render(self, obj: DirectoryListing) -> str:
return self._limit_length(self._to_json({"dirs": obj.dirs, "files": obj.files}))
class PatternMatches(RepresentableViaRenderer):
"""
The matches of a pattern search (`MatchedConsecutiveLines`).
"""
def __init__(self, matches: list[MatchedConsecutiveLines], renderer: "PatternMatchesRenderer"):
"""
:param matches: the matches
:param renderer: the renderer to use for representing the matches
"""
super().__init__(renderer)
self.matches = matches
matches: list[MatchedConsecutiveLines]
def __len__(self) -> int:
return len(self.matches)
def matches_by_file_(self) -> dict[str, list[MatchedConsecutiveLines]]:
result: defaultdict[str, list[MatchedConsecutiveLines]] = defaultdict(list)
for match in self.matches:
assert match.source_file_path is not None
result[match.source_file_path].append(match)
return result
class PatternMatchesRenderer(Renderer[PatternMatches]):
"""
Renders matches as a mapping from file paths to matched line blocks (with context), falling back to progressively
shorter representations (first lines, truncated first lines, line numbers, per-file counts, a summary) if the
length limit is exceeded.
"""
_TEXT_TRUNCATE = 60
def render(self, obj: PatternMatches) -> str:
matches_by_file = obj.matches_by_file_()
file_to_matches = {path: [m.to_display_string() for m in matches] for path, matches in matches_by_file.items()}
# capture lightweight match data for shortening before serialization
match_lines_by_file = {
path: [{"line": m.matched_lines[0].line_number, "text": m.matched_lines[0].line_content.strip()} for m in matches]
for path, matches in matches_by_file.items()
}
# shortened result closures, from least to most aggressive shortening
def render_first_lines(truncate: bool) -> str:
"""Render each match's first line, either in full or truncated to a fixed length."""
def entry_text(text: str) -> str:
if truncate and len(text) > self._TEXT_TRUNCATE:
return text[: self._TEXT_TRUNCATE] + "..."
return text
compact = {
path: [{"line": m["line"], "text": entry_text(str(m["text"]))} for m in lines]
for path, lines in match_lines_by_file.items()
}
if truncate:
header = (
f"Matched lines (text over {self._TEXT_TRUNCATE} chars is truncated, marked with a trailing '...'); "
"use read_file with the line numbers for full content:"
)
else:
header = "Matched lines per file; use read_file with the line numbers for surrounding context:"
return f"{header}\n{self._to_json(compact)}"
def make_first_lines_full() -> str:
return render_first_lines(truncate=False)
def make_first_lines_truncated() -> str:
return render_first_lines(truncate=True)
def make_line_numbers_only() -> str:
numbers = {path: [m["line"] for m in lines] for path, lines in match_lines_by_file.items()}
return f"Match lines per file:\n{self._to_json(numbers)}"
def make_per_file_counts() -> str:
counts = {path: len(lines) for path, lines in match_lines_by_file.items()}
return f"Match counts per file:\n{self._to_json(counts)}"
def make_summary() -> str:
return f"Found {len(obj)} matches in {len(match_lines_by_file)} files."
return self._limit_length(
self._to_json(file_to_matches),
shortened_result_factories=[
make_first_lines_full,
make_first_lines_truncated,
make_line_numbers_only,
make_per_file_counts,
make_summary,
],
)
class FsApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(
agent,
name="fs",
description="the project's files as units (as opposed to their content, see `edit`)",
types=[
ReferencedType(
MatchedConsecutiveLines, members=["source_file_path", "matched_lines", "start_line", "end_line", "to_display_string"]
),
],
)
@facade_method(corresponding_tool=ReadFileTool)
def read_file(self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = -1) -> FileContent:
"""
Reads the given file or a range of its lines.
:param relative_path: the relative path to the file to read
:param start_line: the 0-based index of the first line to be retrieved, negative values count from the end of the file.
:param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file.
:return: the content
"""
project = self._get_project()
project.validate_relative_path(relative_path)
# read lines, using the same (LSP-compliant) notion of line breaks as the line-based editing operations
lines = TextUtils.split_lines(project.read_file(relative_path))
lines = lines[start_line:] if end_line is None else lines[start_line : end_line + 1]
return FileContent(lines, FileContentRenderer(self._agent, max_answer_chars))
@facade_method(can_edit=True, corresponding_tool=CreateTextFileTool)
def create_text_file(self, relative_path: str, content: str) -> str:
"""
Writes a new file or overwrites an existing file with the given content.
:param relative_path: the relative path to the file to create
:param content: the (appropriately encoded) content to write to the file
:return: a message indicating success
"""
project = self._get_project()
project_root = Path(project.project_root)
abs_path = (project_root / relative_path).resolve()
will_overwrite_existing = abs_path.exists()
# validate the destination path
if will_overwrite_existing:
project.validate_relative_path(relative_path)
else:
assert abs_path.is_relative_to(project_root), f"Cannot create file outside of the project directory, got {relative_path=}"
# write the file
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content, encoding=project.project_config.encoding, newline=project.line_ending.newline_str)
answer = f"File created: {relative_path}."
if will_overwrite_existing:
answer += " Overwrote existing file."
return answer
@facade_method(corresponding_tool=ListDirTool)
def list_dir(
self, relative_path: str, recursive: bool, skip_ignored_files: bool = False, max_answer_chars: int = -1
) -> DirectoryListing:
"""
Lists files and directories in the given directory (optionally with recursion).
:param relative_path: the relative path to the directory to list; pass "." to scan the project root
:param recursive: whether to scan subdirectories recursively
:param skip_ignored_files: whether to skip files and directories that are ignored
:return: the listing
"""
project = self._get_project()
if not project.relative_path_exists(relative_path):
raise FileNotFoundError(f"Directory not found: {relative_path} (check if the path is correct relative to the project root)")
project.validate_relative_path(relative_path)
is_ignored_path_fn = project.get_is_ignored_path_fn(relative_path, skip_ignored_files)
dirs, files = scan_directory(
os.path.join(project.project_root, relative_path),
relative_to=project.project_root,
recursive=recursive,
is_ignored_dir=is_ignored_path_fn,
is_ignored_file=is_ignored_path_fn,
)
return DirectoryListing(dirs, files, DirectoryListingRenderer(self._agent, max_answer_chars))
@facade_method(corresponding_tool=FindFileTool)
def find_file(self, file_mask: str, relative_path: str) -> list[str]:
"""
Finds files matching the given file mask within the given relative path.
:param file_mask: the filename or file mask (using the wildcards * or ?) to search for
:param relative_path: the relative path to the directory to search in; pass "." to scan the project root
:return: the relative paths of the matching files
"""
project = self._get_project()
project.validate_relative_path(relative_path)
is_ignored_path_fn = project.get_is_ignored_path_fn(relative_path, skip_ignored_paths=False)
# find the files by ignoring everything that doesn't match
def is_ignored_file(abs_path: str) -> bool:
if is_ignored_path_fn(abs_path):
return True
return not fnmatch(os.path.basename(abs_path), file_mask)
_dirs, files = scan_directory(
path=os.path.join(project.project_root, relative_path),
recursive=True,
is_ignored_dir=is_ignored_path_fn,
is_ignored_file=is_ignored_file,
relative_to=project.project_root,
)
return files
@facade_method(corresponding_tool=SearchForPatternTool)
def search_for_pattern(
self,
substring_pattern: str,
context_lines_before: int = 0,
context_lines_after: int = 0,
paths_include_glob: str = "",
paths_exclude_glob: str = "",
relative_path: str = "",
restrict_search_to_code_files: bool = False,
skip_ignored_files: bool = True,
multiline: bool = True,
max_answer_chars: int = -1,
) -> PatternMatches:
"""
Searches for a regex pattern across project files, returning whole matched lines (plus optional context).
Prefer symbolic operations if you know which symbols you are looking for!
:param substring_pattern: regular expression to search for.
:param context_lines_before: number of context lines to include before each match.
:param context_lines_after: number of context lines to include after each match.
:param paths_include_glob: optional glob (relative to project root, e.g. ``"src/**/*.ts"``) restricting which files are searched.
:param paths_exclude_glob: optional glob to exclude files; takes precedence over `paths_include_glob`.
:param relative_path: restricts the search to this file or subdirectory of the project root
:param restrict_search_to_code_files: whether to search only (non-ignored) files containing analyzable code symbols
(useful when looking for class/method definitions); otherwise also search non-code files.
:param skip_ignored_files: whether to skip ignored sub-paths (default: True)
:param multiline: whether to apply multi-line matching (default: True), enabling the flags re.DOTALL and re.MULTILINE
:return: the matches, rendered as a mapping from file paths to matched consecutive lines (0-based line numbers)
"""
project = self._get_project()
relative_path = relative_path.strip()
if relative_path:
project.validate_relative_path(relative_path)
matches = project.search_project_files_for_pattern(
pattern=substring_pattern,
relative_path=relative_path,
context_lines_before=context_lines_before,
context_lines_after=context_lines_after,
paths_include_glob=paths_include_glob.strip(),
paths_exclude_glob=paths_exclude_glob.strip(),
multiline=multiline,
code_files_only=restrict_search_to_code_files,
skip_ignored_files=skip_ignored_files,
)
return PatternMatches(matches, PatternMatchesRenderer(self._agent, max_answer_chars))
+642
View File
@@ -0,0 +1,642 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of JetBrains IDE-backed operations.
"""
from collections import Counter, defaultdict
from collections.abc import Iterator
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Literal
import serena.jetbrains.jetbrains_types as jb
from serena.code_editor import JetBrainsCodeEditor
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOUtil
from serena.symbol import JetBrainsSymbolDictGrouper
from serena.tools import (
JetBrainsDebugTool,
JetBrainsFindDeclarationTool,
JetBrainsFindImplementationsTool,
JetBrainsFindReferencingSymbolsTool,
JetBrainsFindSymbolTool,
JetBrainsGetSymbolsOverviewTool,
JetBrainsInlineSymbol,
JetBrainsListInspectionsTool,
JetBrainsMoveTool,
JetBrainsRenameTool,
JetBrainsRunInspectionsTool,
JetBrainsSafeDeleteTool,
JetBrainsTypeHierarchyTool,
)
from serena.util.text_utils import find_text_coordinates
from ..facade import FacadeApi, facade_method
from ..representable import JsonObject, JsonObjectRenderer, Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class JetBrainsSymbolCollection(RepresentableViaRenderer):
"""
A collection of symbols retrieved via the JetBrains backend.
Each symbol is a dict with keys such as `name_path`, `relative_path` and `type`, and optionally
`children`, `body`, `quick_info`, `documentation` and (for references) `context`.
"""
symbols: list[SymbolDTO]
def __init__(self, symbols: list[SymbolDTO], renderer: "JetBrainsSymbolCollectionRenderer"):
"""
:param symbols: the symbols
:param renderer: the renderer to use for representing the collection
"""
super().__init__(renderer)
self.symbols = symbols
def __len__(self) -> int:
return len(self.symbols)
def relative_paths_(self) -> list[str]:
return [s.get("relative_path", "unknown") for s in self.symbols]
def identifiers_(self) -> list[SymbolDTO]:
"""
:return: dicts containing only the identifying information (name_path, type, relative_path) of the symbols
"""
return [{"name_path": s["name_path"], "type": s["type"], "relative_path": s["relative_path"]} for s in self.symbols]
class JetBrainsSymbolCollectionRenderer(Renderer[JetBrainsSymbolCollection]):
"""
Renders a symbol collection as (optionally grouped) JSON, falling back to a listing of symbol identifiers
if the length limit is exceeded.
"""
def __init__(self, agent: "SerenaAgent", max_answer_chars: int, grouper: JetBrainsSymbolDictGrouper | None = None):
super().__init__(agent, max_answer_chars)
self._grouper = grouper
def _group(self, symbols: list[SymbolDTO]) -> Any:
return self._grouper.group(symbols) if self._grouper is not None else symbols
def render_identifiers(self, obj: JetBrainsSymbolCollection) -> str:
"""
:return: a shortened representation containing symbol types and identifiers (path + name_path) only, without children
"""
return f"Names with paths:\n{self._to_json(self._group(obj.identifiers_()))}"
def render(self, obj: JetBrainsSymbolCollection) -> str:
result = self._to_json(self._group(obj.symbols))
return self._limit_length(result, shortened_result_factories=[lambda: self.render_identifiers(obj)])
class JetBrainsReferencesRenderer(JetBrainsSymbolCollectionRenderer):
"""
Renders a collection of referencing symbols, falling back to per-file counts and finally the total count
if the length limit is exceeded.
"""
def render(self, obj: JetBrainsSymbolCollection) -> str:
ref_paths = obj.relative_paths_()
result = self._to_json(self._group(obj.symbols))
return self._limit_length(
result,
shortened_result_factories=[
lambda: f"Reference counts per file:\n{self._to_json(Counter(ref_paths))}",
lambda: f"Found {len(ref_paths)} references.",
],
)
class JetBrainsSymbolsOverview(RepresentableViaRenderer):
"""
The overview of the symbols defined in a file, i.e. the top-level symbols (each a dict with keys such as
`name_path` and `type`, optionally with `children`) and, if requested, the file's documentation.
"""
def __init__(self, symbols: list[SymbolDTO], documentation: str | None, renderer: "JetBrainsSymbolsOverviewRenderer"):
"""
:param symbols: the top-level symbols
:param documentation: the file's documentation, if requested and present
:param renderer: the renderer to use for representing the overview
"""
super().__init__(renderer)
self.symbols = symbols
self.documentation = documentation
symbols: list[SymbolDTO]
documentation: str | None
class JetBrainsSymbolsOverviewRenderer(Renderer[JetBrainsSymbolsOverview]):
"""
Renders an overview in the compact grouped format, dropping (in order) the documentation, the children
and finally everything but symbol counts by type if the length limit is exceeded.
"""
def __init__(self, agent: "SerenaAgent", max_answer_chars: int, grouper: JetBrainsSymbolDictGrouper, depth: int):
super().__init__(agent, max_answer_chars)
self._grouper = grouper
self._depth = depth
def render(self, obj: JetBrainsSymbolsOverview) -> str:
grouped_symbols = self._grouper.group(obj.symbols)
shortened_result_factories = []
# create the full result
result: dict[str, Any] = {"symbols": grouped_symbols}
if obj.documentation:
result["docstring"] = obj.documentation
shortened_result_factories.append(lambda: self._to_json(grouped_symbols)) # shortened result without docstring
json_result = self._to_json(result)
# create shortened results
if self._depth > 0:
def create_short_result_depth_0() -> str:
depth_0_symbols = [d.copy() for d in obj.symbols]
for d in depth_0_symbols:
d.pop("children", None)
return "Depth 0 overview:\n" + self._to_json(self._grouper.group(depth_0_symbols))
shortened_result_factories.append(create_short_result_depth_0)
def create_short_result_type_counts() -> str:
type_names = [d.get("type", "unknown") for d in obj.symbols]
return f"Symbol counts by type:\n{self._to_json(Counter(type_names))}"
shortened_result_factories.append(create_short_result_type_counts)
return self._limit_length(json_result, shortened_result_factories=shortened_result_factories)
class JetBrainsApi(FacadeApi):
# groupers for the various symbol collections; top-level symbols are grouped by the first key list,
# children by the second
find_symbol_grouper_ = JetBrainsSymbolDictGrouper(
["relative_path", "type"], ["type"], collapse_singleton=True, map_name_path_to_name=True
)
references_grouper_ = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True)
overview_grouper_ = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True)
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(
agent,
name="jb",
description="operations on the codebase backed by the JetBrains IDE's code intelligence",
)
@contextmanager
def _client(self) -> Iterator[JetBrainsPluginClient]:
with JetBrainsPluginClient.from_project(self._get_project()) as client:
yield client
def _json_object(self, data: Any, max_answer_chars: int = -1) -> JsonObject:
return JsonObject(data, JsonObjectRenderer(self._agent, max_answer_chars))
# read operations
@facade_method(corresponding_tool=JetBrainsFindSymbolTool)
def find_symbol(
self,
name_path_pattern: str,
depth: int = 0,
relative_path: str | None = None,
include_body: bool = False,
include_info: bool = False,
search_deps: bool = False,
max_matches: int = -1,
max_answer_chars: int = -1,
) -> JetBrainsSymbolCollection:
"""
Finds symbols and code entities (classes, methods, etc.) based on the given name path pattern.
The returned symbol information can be used for edits or further queries.
Specify `depth > 0` to retrieve children (e.g., methods of a class).
Important: through `search_deps=True` dependencies can be searched, which
should be preferred to web search or other less sophisticated approaches to analyzing dependencies.
You will always receive at least quick info for returned symbols (even if `include_info=False`).
A name path is a path in the symbol tree *within a source file*.
For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`.
If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to
uniquely identify it.
To search for a symbol, you provide a name path pattern that is used to match against name paths.
It can be
* a simple name (e.g. "method"), which will match any symbol with that name
* a relative path like "class/method", which will match any symbol with that name path suffix
* an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file.
Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]".
In any path component, using `*` will match any sequence of characters (excluding /), e.g. "Class/*substring*" matches a member substring.
A pattern must not contain only wildcards (e.g. "*" or "/*"); use `get_symbols_overview` to list a file's symbols.
:param name_path_pattern: the name path matching pattern (see above)
:param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children;
for the case where the symbol is a class, this will return its methods).
Ignored if `include_body=True`. Default 0.
:param relative_path: Optional. Restrict search to this file or directory. If not specified, searches entire codebase.
Note: for external dependencies, this must be an identifier starting with `<ext` that you have received
earlier (don't try to guess!).
:param include_body: If True, include the symbol's full source code.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the symbol.
Default False; info is never included for child symbols or if include_body is True.
:param search_deps: If True, also search in project dependencies (e.g., libraries).
:param max_matches: Maximum number of permitted matches. If exceeded, an error containing a shortened result is raised,
which allows refining the search. -1 (default) means no limit. Set to 1 if you search for a single symbol.
:return: the symbols matching the pattern
"""
if name_path_pattern.replace("*", "").replace("/", "") == "":
raise ValueError("name_path_pattern must not be empty or contain only wildcards; consider using get_symbols_overview")
if include_body:
depth = 0 # ignore user-specified depth if body is requested
if relative_path == ".":
relative_path = None
if relative_path is not None and relative_path.startswith(jb.JB_EXTERNAL_FILE_PREFIX):
search_deps = True
# determine which additional information to request: if no additional information is requested,
# we still include the quick info (type signature)
include_documentation = include_info and not include_body
include_quick_info = not include_info and not include_body
with self._client() as client:
response = client.find_symbol(
name_path=name_path_pattern,
relative_path=relative_path,
depth=depth,
include_body=include_body,
include_documentation=include_documentation,
include_quick_info=include_quick_info,
search_deps=search_deps,
)
renderer = JetBrainsSymbolCollectionRenderer(self._agent, max_answer_chars, grouper=self.find_symbol_grouper_)
collection = JetBrainsSymbolCollection(response["symbols"], renderer)
n_matches = len(collection)
if 0 < max_matches < n_matches:
raise ValueError(f"Matched {n_matches}>{max_matches=} symbols.\n" + renderer.render_identifiers(collection))
return collection
@facade_method(corresponding_tool=JetBrainsFindReferencingSymbolsTool)
def find_referencing_symbols(self, name_path: str, relative_path: str, max_answer_chars: int = -1) -> JetBrainsSymbolCollection:
"""
Finds all symbols that reference the given symbol (its callers / usages / dependents)
i.e. the symbols whose own definition (e.g. a method body) contains a reference to it.
For each, returns its name path, file, and the surrounding line of code.
:param name_path: name path of the symbol for which to find references
:param relative_path: the relative path to the file containing the symbol (must be a file, not a directory)
Note: for external dependencies, this must be an identifier starting with `<ext` that you have received
earlier (don't try to guess!).
:return: the referencing symbols
"""
with self._client() as client:
response = client.find_references(name_path=name_path, relative_path=relative_path, include_quick_info=False)
symbol_dicts = response["symbols"]
# replace reference line number (if present) by actual line/context
project = self._get_project()
for symbol_dict in symbol_dicts:
if "reference_line_no" in symbol_dict:
ref_line = symbol_dict["reference_line_no"]
if not SymbolDTOUtil.is_external_symbol(symbol_dict) and ref_line is not None and ref_line >= 0:
content_around_ref = project.retrieve_content_around_line(
relative_file_path=symbol_dict["relative_path"], line=ref_line, context_lines_before=1, context_lines_after=1
)
symbol_dict["context"] = content_around_ref.to_display_string()
del symbol_dict["reference_line_no"]
renderer = JetBrainsReferencesRenderer(self._agent, max_answer_chars, grouper=self.references_grouper_)
return JetBrainsSymbolCollection(symbol_dicts, renderer)
@facade_method(corresponding_tool=JetBrainsGetSymbolsOverviewTool)
def get_symbols_overview(
self, relative_path: str, depth: int = -1, max_answer_chars: int = -1, include_file_documentation: bool = False
) -> JetBrainsSymbolsOverview:
"""
Gets an overview of the top-level symbols defined in the given file (classes, methods, fields).
Returns STRUCTURE only, without bodies. This is the cheap, structure-first way to learn what a file
contains: it costs far less context than reading the whole file.
:param relative_path: the relative path to the file to get the overview of
:param depth: depth up to which descendants shall be retrieved.
Default (-1) results in a language specific choice: 1 for java and kotlin and 0 for other languages
:param include_file_documentation: whether to include the file's docstring. Default False.
:return: the overview
"""
if depth == -1:
depth = 1 if relative_path.endswith((".java", ".kt")) else 0
with self._client() as client:
response = client.get_symbols_overview(
relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation
)
renderer = JetBrainsSymbolsOverviewRenderer(self._agent, max_answer_chars, grouper=self.overview_grouper_, depth=depth)
return JetBrainsSymbolsOverview(response["symbols"], response.get("documentation"), renderer)
@staticmethod
def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]:
"""
Transforms a list of hierarchy nodes into a file-grouped compact format.
:return: a dict where keys are relative paths and values are lists of either a name path (for a leaf node)
or a dict mapping the name path to the (recursively transformed) children
"""
result: defaultdict[str, list] = defaultdict(list)
for node in nodes or []:
symbol = node["symbol"]
name_path = symbol["name_path"]
rel_path = symbol["relative_path"]
children = node.get("children", [])
if children:
result[rel_path].append({name_path: JetBrainsApi._transform_hierarchy_nodes(children)})
else:
result[rel_path].append(name_path)
return dict(result)
@facade_method(corresponding_tool=JetBrainsTypeHierarchyTool)
def get_type_hierarchy(
self,
name_path: str,
relative_path: str,
hierarchy_type: Literal["super", "sub", "both"] = "both",
depth: int | None = 1,
max_answer_chars: int = -1,
) -> JsonObject:
"""
Gets the type hierarchy of a symbol (supertypes, subtypes, or both).
:param name_path: name path of the symbol for which to get the type hierarchy.
:param relative_path: the relative path to the file containing the symbol.
:param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces,
"sub" for subclasses/implementations, or "both" for both directions. Default is "both".
:param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1.
:return: the file-grouped hierarchy, with keys "supertypes" and/or "subtypes" (and "levels_not_included"
if the depth limit truncated the hierarchy)
"""
result: dict[str, dict | list] = {}
levels_not_included = {}
with self._client() as client:
if hierarchy_type in ("super", "both"):
response = client.get_supertypes(name_path=name_path, relative_path=relative_path, depth=depth)
if "num_levels_not_included" in response:
levels_not_included["supertypes"] = response["num_levels_not_included"]
result["supertypes"] = self._transform_hierarchy_nodes(response.get("hierarchy"))
if hierarchy_type in ("sub", "both"):
response = client.get_subtypes(name_path=name_path, relative_path=relative_path, depth=depth)
if "num_levels_not_included" in response:
levels_not_included["subtypes"] = response["num_levels_not_included"]
result["subtypes"] = self._transform_hierarchy_nodes(response.get("hierarchy"))
if levels_not_included:
result["levels_not_included"] = levels_not_included
return self._json_object(result, max_answer_chars)
@facade_method(corresponding_tool=JetBrainsFindDeclarationTool)
def find_declaration(self, relative_path: str, regex: str, include_body: bool = False) -> JetBrainsSymbolCollection:
r"""
Finds the declaration of a symbol based on an occurrence of the symbol in a source file, specified by a regex.
:param relative_path: the relative path to the source file containing the symbol for which to find the declaration.
:param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup.
For example, to find the declaration of the `process` method in a call like `obj.process()`,
pass an expression like "obj\.(process)\(process_input_arg=37\)".
Prefer regexes with sufficiently large context around the group to render the match unambiguous.
Uses Python syntax with MULTILINE and DOTALL flags enabled.
:param include_body: whether to include the symbol's body in the result. Default False.
:return: the declaring symbol(s)
"""
content = self._get_project().read_file(relative_path)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
with self._client() as client:
response = client.find_declaration(
relative_path=relative_path, line=coords.line, col=coords.col, include_quick_info=False, include_body=include_body
)
return JetBrainsSymbolCollection(response["symbols"], JetBrainsSymbolCollectionRenderer(self._agent, -1))
@facade_method(corresponding_tool=JetBrainsFindImplementationsTool)
def find_implementations(self, relative_path: str, name_path: str) -> JetBrainsSymbolCollection:
"""
Finds the implementations of a symbol.
:param relative_path: the relative path to the source file containing the symbol for which to find implementations.
:param name_path: name path of the symbol for which to find implementations
:return: the implementing symbols
"""
with self._client() as client:
response = client.find_implementations(relative_path=relative_path, name_path=name_path, include_quick_info=False)
return JetBrainsSymbolCollection(response["symbols"], JetBrainsSymbolCollectionRenderer(self._agent, -1))
# edit operations
@facade_method(can_edit=True, corresponding_tool=JetBrainsRenameTool)
def rename(
self,
relative_path: str,
new_name: str,
name_path: str | None = None,
rename_in_comments: bool = False,
rename_in_text_occurrences: bool = False,
) -> JsonObject:
"""
Renames a symbol, file or directory throughout the codebase.
Note: renaming in comments/text is on a best-effort basis by the IDE; if the symbol name is non-unique, further
verification is recommended.
:param relative_path: if `name_path` is passed, the relative path of the file containing the symbol.
Otherwise, the path to the directory or file to rename.
:param new_name: the new name
:param name_path: the name path of the symbol to rename or None if renaming a file or directory.
:param rename_in_comments: whether to also rename occurrences in comments. Default False.
:param rename_in_text_occurrences: whether to also rename occurrences in text. Default False.
:return: the result of the operation
"""
result = JetBrainsCodeEditor(self._get_project()).rename_symbol(
name_path=name_path,
relative_path=relative_path,
new_name=new_name,
rename_in_comments=rename_in_comments,
rename_in_text_occurrences=rename_in_text_occurrences,
)
return self._json_object(result)
@facade_method(beta=True, can_edit=True, corresponding_tool=JetBrainsMoveTool)
def move(
self,
relative_path: str,
name_path: str | None = None,
target_relative_path: str | None = None,
target_parent_name_path: str | None = None,
) -> JsonObject:
"""
Moves a symbol, file or directory to a different location and automatically updates all references to affected symbols.
**Important**: this should always be preferred to naive moving (e.g. via file system operations or edits)
as it is much more reliable and efficient. It is always safe to use. For some symbols, moving may not be applicable,
and will result in no edits and a suitable error message.
The target location is the new parent of the symbol,
i.e. the moved entity is never renamed by the operation, only moved.
Valid moves:
- Symbol:
* (relative_path, name_path) -> new parent symbol (target_relative_path, target_parent_name_path)
* (relative_path, name_path) -> top level of target file or directory (target_relative_path)
Always consider the concrete language-specific semantics!
- target is a file: valid for languages like Python, where files are modules
- target is a directory: valid for languages like Java, where directories are packages and can contain classes
- File or directory:
* relative_path -> new parent directory (target_relative_path)
:param relative_path: the relative path to the file containing the symbol to move.
:param name_path: the name path of the symbol to move (empty for moving file or dir).
:param target_relative_path: the relative path of the target directory or file.
:param target_parent_name_path: the name path of the target parent symbol.
:return: the result of the operation
"""
with self._client() as client:
result = client.move(
name_path=name_path or None,
relative_path=relative_path,
target_parent_name_path=target_parent_name_path or None,
target_relative_path=target_relative_path or None,
)
return self._json_object(result)
@facade_method(beta=True, can_edit=True, corresponding_tool=JetBrainsSafeDeleteTool)
def safe_delete(
self, relative_path: str, name_path: str | None = None, delete_even_if_used: bool = False, propagate: bool = False
) -> JsonObject:
"""
Safely deletes a symbol, file, or directory, checking for usages first and propagating deletion, if desired.
Propagation means it is possible to request deleting of usages and cleaning up of unused code.
Propagation is powerful for cleaning up code but should be used with care.
**Important**: this should always be preferred to naive deleting (e.g. via file system operations or edits).
When using it, you don't have to search for usages first, as the operation will do it for you.
:param relative_path: the relative path to the file containing the symbol to delete.
:param name_path: the name path of the symbol to delete.
A name path identifies a symbol within a source file, e.g. "MyClass/my_method".
Omit for deleting a file or directory.
:param delete_even_if_used: whether to force deletion even if the symbol still has usages.
Default is False (safe mode: will report usages instead of deleting).
:param propagate: whether to propagate the deletion to usages of the symbol and also
remove symbols that become unused after the deletion. Default is False.
:return: the result of the operation
"""
with self._client() as client:
result = client.safe_delete(
name_path=name_path or None, relative_path=relative_path, delete_even_if_used=delete_even_if_used, propagate=propagate
)
return self._json_object(result)
@facade_method(beta=True, can_edit=True, corresponding_tool=JetBrainsInlineSymbol)
def inline_symbol(self, name_path: str, relative_path: str, keep_definition: bool = False) -> JsonObject:
"""
Inlines a symbol, replacing all call sites with the symbol's body.
**Important**: this should always be preferred to naive inlining (e.g. via searching for references and
editing them).
:param name_path: the name path of the symbol to inline (usually a method/function, but also classes may be amenable to inlining,
which turns invocation into anonymous class creation)
:param relative_path: the relative path to the file containing the symbol to inline.
:param keep_definition: whether to keep the original method definition after inlining all call sites.
May be ignored in some cases (e.g. when inlining a class).
:return: the result of the operation
"""
with self._client() as client:
result = client.inline_symbol(name_path=name_path, relative_path=relative_path, keep_definition=keep_definition)
return self._json_object(result)
# inspections
@facade_method(corresponding_tool=JetBrainsRunInspectionsTool)
def run_inspections(
self,
relative_path: str,
min_severity: str | None = None,
inspection_names: list[str] | None = None,
start_line: int | None = None,
end_line: int | None = None,
max_answer_chars: int = -1,
) -> JsonObject:
"""
Runs IDE inspections (code analysis) on the given file and returns the problems found.
This leverages the full power of JetBrains' static analysis engine, including language-specific
inspections, type checking, potential bugs, code style issues, and more.
:param relative_path: the relative path to the file to inspect.
:param min_severity: minimum severity level to include in results (e.g. "ERROR", "WARNING", "WEAK_WARNING", "INFO").
If not specified, all severities are returned.
:param inspection_names: optional list of specific inspection names to run (e.g. ["UnusedImport", "TypeMismatch"]).
If not specified, all applicable inspections are run.
:param start_line: optional 1-based start line to restrict the inspection range.
:param end_line: optional 1-based end line to restrict the inspection range.
:return: the inspection results including severity, message, and location.
"""
with self._client() as client:
result = client.run_inspections(
relative_path=relative_path,
min_severity=min_severity,
inspection_names=inspection_names,
start_line=start_line,
end_line=end_line,
)
return self._json_object(result, max_answer_chars)
@facade_method(corresponding_tool=JetBrainsListInspectionsTool)
def list_inspections(
self, language: str | None = None, group_path_contains: str | None = None, max_answer_chars: int = -1
) -> JsonObject:
"""
Lists available IDE inspections.
Use this to discover which inspections can be passed to `run_inspections` via `inspection_names`.
:param language: optional language to filter by (e.g. "Java", "Python", "Kotlin").
:param group_path_contains: optional substring to match against the inspection group path
(e.g. "probable bugs", "code style").
:return: the list of available inspections including name, group path, and language.
"""
with self._client() as client:
result = client.list_inspections(language=language, group_path_contains=group_path_contains)
return self._json_object(result, max_answer_chars)
# debugging
@facade_method()
def debug_eval_info(self) -> str:
"""
Provides usage information for the debug REPL (method `debug_eval`)
:return: the usage information
"""
return self._agent.prompt_factory.create_info_jet_brains_debug_repl()
@facade_method(corresponding_tool=JetBrainsDebugTool)
def debug_eval(self, expression: str, repl_key: str = "default") -> str:
"""
Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation)
via a persistent debug REPL connected to the JetBrains IDE.
Call `debug_eval_info()` first for usage information.
:param expression: a Groovy/Java expression/statement to evaluate in the REPL.
If empty, closes the REPL with the given key.
:param repl_key: identifier for the REPL instance. State persists across calls with the same key.
:return: the string representation of the result
"""
with self._client() as client:
if expression:
response = client.debug_eval(repl_key=repl_key, expression=expression)
else:
response = client.debug_close(repl_key=repl_key)
return response.get("result", str(response))
+789
View File
@@ -0,0 +1,789 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of language server (LSP)-backed operations.
"""
import os
from collections import Counter, defaultdict
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from serena.code_editor import LanguageServerCodeEditor
from serena.lsp.lsp_diagnostics import GroupedDiagnostics
from serena.symbol import (
LanguageServerSymbol,
LanguageServerSymbolDictGrouper,
LanguageServerSymbolRetriever,
ReferenceInLanguageServerSymbol,
SymbolDictGrouper,
)
from serena.tools import (
FindDeclarationTool,
FindImplementationsTool,
FindReferencingSymbolsTool,
FindSymbolTool,
GetDiagnosticsForFileTool,
GetDiagnosticsForSymbolTool,
GetSymbolsOverviewTool,
RenameSymbolTool,
RestartLanguageServerTool,
SafeDeleteSymbol,
)
from serena.util.text_utils import TextOutputUtils, find_text_coordinates
from solidlsp.lsp_protocol_handler.lsp_types import SymbolKind
from ..facade import SUCCESS_RESULT, FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class LspSymbolCollection(RepresentableViaRenderer):
"""
A collection of symbols (`LanguageServerSymbol`) retrieved via the language server.
"""
symbols: list[LanguageServerSymbol]
def __init__(
self,
symbols: list[LanguageServerSymbol],
renderer: "LspSymbolCollectionRenderer",
info_by_symbol: dict[LanguageServerSymbol, str] | None = None,
):
"""
:param symbols: the list of symbols
:param renderer: the renderer to use for representing the collection
:param info_by_symbol: additional (hover-like) info per symbol, if requested
"""
super().__init__(renderer)
self.symbols = symbols
self.info_by_symbol_ = info_by_symbol or {}
def __len__(self) -> int:
return len(self.symbols)
def relative_path_to_name_paths_(self) -> dict[str, list[str]]:
result: defaultdict[str, list[str]] = defaultdict(list)
for s in self.symbols:
result[s.location.relative_path or "unknown"].append(s.get_name_path())
return result
class LspSymbol(RepresentableViaRenderer):
"""
A single symbol retrieved via the language server (see `LspSymbolCollection` for the symbol's interface).
"""
symbol: LanguageServerSymbol
def __init__(self, symbol: LanguageServerSymbol, renderer: "LspSymbolRenderer", info: str | None = None):
"""
:param symbol: the symbol
:param renderer: the renderer to use for representing the symbol
:param info: additional (hover-like) info on the symbol, if requested
"""
super().__init__(renderer)
self.symbol = symbol
self.info_ = info
@dataclass(kw_only=True)
class SymbolOutputParams:
name_path: bool = True
name: bool = False
kind: bool = False
location: bool = False
depth: int = 0
body_location: bool = False
children_body: bool = False
children_name: bool | None = None
children_name_path: bool | None = None
relative_path: bool = False
include_body: bool = False
include_info: bool = False
child_inclusion_predicate: Callable[[LanguageServerSymbol], bool] | None = None
class LspSymbolCollectionRenderer(Renderer[LspSymbolCollection]):
"""
Renders a symbol collection as (optionally grouped) JSON according to the output parameters, falling back
to a mapping from files to name paths if the length limit is exceeded.
"""
def __init__(
self,
agent: "SerenaAgent",
max_answer_chars: int,
output_params: SymbolOutputParams,
grouper: SymbolDictGrouper | None = None,
):
super().__init__(agent, max_answer_chars)
self._output_params = output_params
self._grouper = grouper
def symbol_dicts_(
self, symbols: list[LanguageServerSymbol], info_by_symbol: dict[LanguageServerSymbol, str]
) -> list[LanguageServerSymbol.OutputDict]:
"""
:param symbols: the symbols to convert
:param info_by_symbol: additional info to include per symbol, if any
:return: the dict representations of the symbols according to the output parameters (including the info)
"""
p = self._output_params
symbol_dicts = [
s.to_dict(
kind=p.kind,
name_path=p.name_path,
name=p.name,
location=p.location,
relative_path=p.relative_path,
body_location=p.body_location,
depth=p.depth,
body=p.include_body,
children_body=p.children_body,
children_name=p.children_name,
children_name_path=p.children_name_path,
child_inclusion_predicate=p.child_inclusion_predicate,
)
for s in symbols
]
for s, s_dict in zip(symbols, symbol_dicts, strict=True):
if symbol_info := info_by_symbol.get(s):
# In python 3.15 we could specify extra_items=True in the TypedDict definition,
# https://peps.python.org/pep-0728/
# If we ever upgrade to 3.15, we can remove the type: ignore[typeddict-unknown-key]
s_dict["info"] = symbol_info
return symbol_dicts
def _group(self, symbol_dicts: list[LanguageServerSymbol.OutputDict]) -> Any:
return self._grouper.group(symbol_dicts) if self._grouper is not None else symbol_dicts
def render(self, obj: LspSymbolCollection) -> str:
def create_short_result_relative_path_to_name_paths() -> str:
return f"Shortened result:\n{TextOutputUtils.to_json(obj.relative_path_to_name_paths_())}"
result = self._to_json(self._group(self.symbol_dicts_(obj.symbols, obj.info_by_symbol_)))
return self._limit_length(result, shortened_result_factories=[create_short_result_relative_path_to_name_paths])
class LspSymbolRenderer(Renderer[LspSymbol]):
"""
Renders a single symbol as JSON, using a collection renderer for the conversion.
"""
def __init__(self, agent: "SerenaAgent", max_answer_chars: int, collection_renderer: LspSymbolCollectionRenderer):
super().__init__(agent, max_answer_chars)
self._collection_renderer = collection_renderer
def render(self, obj: LspSymbol) -> str:
info_by_symbol = {obj.symbol: obj.info_} if obj.info_ else {}
symbol_dict = self._collection_renderer.symbol_dicts_([obj.symbol], info_by_symbol)[0]
return self._limit_length(self._to_json(symbol_dict))
class LspSymbolsOverviewRenderer(LspSymbolCollectionRenderer):
"""
Renders a file's symbol overview, falling back to a depth-0 overview and finally symbol counts by kind
if the length limit is exceeded.
"""
def render(self, obj: LspSymbolCollection) -> str:
symbol_dicts = self.symbol_dicts_(obj.symbols, obj.info_by_symbol_)
result = self._to_json(self._group(symbol_dicts))
def make_kind_counts() -> str:
kind_names = [d.get("kind", "unknown") for d in symbol_dicts]
return f"Symbol counts by kind:\n{self._to_json(Counter(kind_names))}"
shortened_results: list[Callable[[], str]] = [make_kind_counts]
if self._output_params.depth > 0:
def make_depth_0_result() -> str:
depth_0_dicts = [d.copy() for d in symbol_dicts]
for d in depth_0_dicts:
d.pop("children", None)
return "Depth 0 overview:\n" + self._to_json(self._group(depth_0_dicts))
shortened_results.insert(0, make_depth_0_result)
return self._limit_length(result, shortened_result_factories=shortened_results)
class LspReferenceCollection(RepresentableViaRenderer):
"""
The references to a symbol (`ReferenceInLanguageServerSymbol`).
"""
references: list[ReferenceInLanguageServerSymbol]
def __init__(
self,
references: list[ReferenceInLanguageServerSymbol],
contents_around_references: list[str],
renderer: "LspReferenceCollectionRenderer",
):
"""
:param references: the references
:param contents_around_references: for each reference, the code around it (for display)
:param renderer: the renderer to use for representing the collection
"""
super().__init__(renderer)
self.references = references
self.contents_around_references_ = contents_around_references
def __len__(self) -> int:
return len(self.references)
class LspReferenceCollectionRenderer(Renderer[LspReferenceCollection]):
"""
Renders references as grouped JSON including the code around each reference, falling back to
references without code, per-file counts and finally the total count if the length limit is exceeded.
"""
def __init__(self, agent: "SerenaAgent", max_answer_chars: int, grouper: SymbolDictGrouper):
super().__init__(agent, max_answer_chars)
self._grouper = grouper
def render(self, obj: LspReferenceCollection) -> str:
reference_dicts = []
ref_summaries = []
for ref, content_around_ref in zip(obj.references, obj.contents_around_references_, strict=True):
ref_dict = dict(ref.symbol.to_dict(kind=True, relative_path=True, depth=0, body=False, body_location=True))
ref_dict["content_around_reference"] = content_around_ref
reference_dicts.append(ref_dict)
ref_summaries.append(
{
"name_path": ref_dict.get("name_path"),
"kind": ref_dict.get("kind"),
"relative_path": ref_dict.get("relative_path"),
"reference_line": ref.line,
}
)
result = self._to_json(self._grouper.group(reference_dicts))
# shortened result closures, from least to most aggressive shortening
def make_refs_without_context() -> str:
return f"References without surrounding lines:\n{self._to_json(self._grouper.group([dict(s) for s in ref_summaries]))}"
def make_per_file_counts() -> str:
counts = Counter(str(r["relative_path"]) for r in ref_summaries)
return f"Reference counts per file:\n{self._to_json(counts)}"
def make_summary() -> str:
return f"Found {len(ref_summaries)} references."
return self._limit_length(result, shortened_result_factories=[make_refs_without_context, make_per_file_counts, make_summary])
class LspDiagnostics(RepresentableViaRenderer):
"""
Diagnostics grouped as `relative_path -> severity -> name_path -> diagnostics`; see `grouped.get_dict()`.
"""
grouped: GroupedDiagnostics
def __init__(self, grouped: GroupedDiagnostics, renderer: "LspDiagnosticsRenderer"):
"""
:param grouped: the grouped diagnostics
:param renderer: the renderer to use for representing the diagnostics
"""
super().__init__(renderer)
self.grouped = grouped
class LspDiagnosticsRenderer(Renderer[LspDiagnostics]):
def render(self, obj: LspDiagnostics) -> str:
return self._limit_length(self._to_json(obj.grouped.get_dict()))
def _is_not_low_level(symbol: LanguageServerSymbol) -> bool:
return not symbol.is_low_level()
class LspApi(FacadeApi):
FILE_LEVEL_DIAGNOSTIC_BUCKET = "<file>"
"""the name path under which diagnostics that cannot be mapped to a symbol are grouped"""
# groupers for the various symbol collections; top-level symbols are grouped by the first key list,
# children by the second.
# For find_symbol, we group children by kind, keeping just the name (the parent's name_path makes it unambiguous);
# we don't group the top-level result list because many tests rely on it being a flat list of symbol dicts
find_symbol_dict_grouper_ = LanguageServerSymbolDictGrouper([], ["kind"], collapse_singleton=True)
references_grouper_ = LanguageServerSymbolDictGrouper(["relative_path", "kind"], ["kind"], collapse_singleton=True)
overview_grouper_ = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True)
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(
agent,
name="lsp",
description="symbol-level operations on the codebase backed by language servers",
types=[
ReferencedType(
LanguageServerSymbol,
members=[
"name",
"get_name_path",
"relative_path",
"symbol_kind_name",
"line",
"column",
"body",
"get_body_line_numbers",
"iter_children",
"iter_ancestors",
"get_parent",
],
),
],
)
def _create_symbol_retriever(self) -> LanguageServerSymbolRetriever:
assert self._agent.get_language_backend().is_lsp(), "Language server operations require the language server backend"
return LanguageServerSymbolRetriever(self._get_project())
def _create_ls_code_editor(self, symbol_retriever: LanguageServerSymbolRetriever | None = None) -> LanguageServerCodeEditor:
return LanguageServerCodeEditor(symbol_retriever or self._create_symbol_retriever())
@staticmethod
def _request_info(
symbol_retriever: LanguageServerSymbolRetriever, symbols: list[LanguageServerSymbol], output_params: SymbolOutputParams
) -> dict[LanguageServerSymbol, str]:
"""
:return: additional (hover-like) info per symbol, if the output parameters request it (and not the body, which
supersedes it); requested eagerly, such that results are self-contained
"""
if output_params.include_info and not output_params.include_body:
return {s: info for s, info in symbol_retriever.request_info_for_symbol_batch(symbols).items() if info}
return {}
def _retrieve_content_around_reference(self, reference: ReferenceInLanguageServerSymbol) -> str:
relative_path = reference.symbol.location.relative_path
assert relative_path is not None, f"Referencing symbol {reference.symbol.name} has no relative path, this is likely a bug."
content = self._get_project().retrieve_content_around_line(
relative_file_path=relative_path, line=reference.line, context_lines_before=1, context_lines_after=1
)
return content.to_display_string()
@staticmethod
def _parse_kinds(kinds: Sequence[int]) -> Sequence[SymbolKind] | None:
return [SymbolKind(k) for k in kinds] if kinds else None
def _create_diagnostics(self, grouped: GroupedDiagnostics, max_answer_chars: int) -> LspDiagnostics:
return LspDiagnostics(grouped, LspDiagnosticsRenderer(self._agent, max_answer_chars))
# language server management
@facade_method(uses_project_server=True, optional=True, corresponding_tool=RestartLanguageServerTool)
def restart_language_server(self) -> str:
"""
Restarts the language server(s).
Use this only on explicit user request or after confirmation; it may be necessary if a language server hangs.
:return: a success message
"""
self._agent.reset_language_server_manager()
return SUCCESS_RESULT
# read operations
@facade_method(uses_project_server=True, corresponding_tool=GetSymbolsOverviewTool)
def get_symbols_overview(self, relative_path: str, depth: int = -1, max_answer_chars: int = -1) -> LspSymbolCollection:
"""
Gets an overview of the symbols defined in the given file (classes, methods, fields, functions, etc.)
Returns STRUCTURE only, without bodies. This is the cheap, structure-first way to learn what a file
contains: it costs far less context than reading the whole file.
:param relative_path: the relative path to the file to get the overview of
:param depth: depth up to which descendants shall be retrieved.
Default (-1) results in a language specific choice: 1 for java and kotlin and 0 for other languages
:return: the top-level symbols of the file
"""
# Note: file system sync not required (relevant file is opened in the language server explicitly)
if depth == -1:
depth = 1 if relative_path.endswith((".java", ".kt")) else 0
symbol_retriever = self._create_symbol_retriever()
# the symbol overview is capable of working with both files and directories, but we require a file
file_path = os.path.join(self._get_project().project_root, relative_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.")
if os.path.isdir(file_path):
raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ")
if not symbol_retriever.can_analyze_file(relative_path):
raise ValueError(
f"Cannot extract symbols from file {relative_path}. "
f"Active language servers: {[l.get_key() for l in self._agent.get_active_language_server_ids()]}"
)
symbols = symbol_retriever.get_symbol_overview(relative_path)[relative_path]
output_params = SymbolOutputParams(
name_path=False,
name=True,
depth=depth,
kind=True,
relative_path=False,
location=False,
child_inclusion_predicate=_is_not_low_level,
)
renderer = LspSymbolsOverviewRenderer(self._agent, max_answer_chars, output_params, grouper=self.overview_grouper_)
return LspSymbolCollection(symbols, renderer)
@facade_method(uses_project_server=True, corresponding_tool=FindSymbolTool)
def find_symbol(
self,
name_path_pattern: str,
depth: int = 0,
relative_path: str = "",
include_body: bool = False,
include_info: bool = False,
include_kinds: Sequence[int] = (),
exclude_kinds: Sequence[int] = (),
substring_matching: bool = False,
max_matches: int = -1,
max_answer_chars: int = -1,
) -> LspSymbolCollection:
"""
Finds symbols and code entities (classes, methods, etc.) based on the given name path pattern.
The returned symbol information can be used for edits or further queries.
Specify `depth > 0` to also retrieve children/descendants (e.g., methods of a class).
A name path is a path in the symbol tree *within a source file*.
For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`.
If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to
uniquely identify it.
To search for a symbol, you provide a name path pattern that is used to match against name paths.
It can be
* a simple name (e.g. "method"), which will match any symbol with that name
* a relative path like "class/method", which will match any symbol with that name path suffix
* an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file.
Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]".
:param name_path_pattern: the name path matching pattern (see above)
:param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children;
for the case where the symbol is a class, this will return its methods).
Ignored if `include_body=True`. Default 0.
:param relative_path: (optional) restrict search to this file or directory. If None, searches entire codebase.
If a directory is passed, the search will be restricted to the files in that directory.
If a file is passed, the search will be restricted to that file.
If you have some knowledge about the codebase, you should use this parameter, as it will significantly
speed up the search as well as reduce the number of results.
:param include_body: If True, include the symbol's source code. Use judiciously.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the symbol (ignored if include_body is True). Info is never included for child symbols.
Note: Depending on the language, this can be slow (e.g., C/C++).
:param include_kinds: (optional) limits results to the given LSP symbol kinds (integers, i.e. values of `SymbolKind`)
:param exclude_kinds: (optional) list of LSP symbol kinds (integers, i.e. values of `SymbolKind`) to exclude.
:param substring_matching: If True, use substring matching for the last segment of `name_path_pattern`
(i.e. the name of the symbol, e.g. "foo" in "Class/foo" or "my_method" in "my_method").
:param max_matches: Maximum number of permitted matches. If exceeded, an error containing a shortened result is raised,
which allows refining the search. -1 (default) means no limit. Set to 1 to search for a unique symbol.
:return: the symbols (with locations) matching the name path pattern
"""
# Note: file system sync not required; the symbol finder opens all relevant source files explicitly in the case of changes
if include_body:
depth = 0 # ignore user-specified depth if include_body is True
assert max_matches != 0, "max_matches must be > 0 or equal to -1."
symbol_retriever = self._create_symbol_retriever()
symbols = symbol_retriever.find(
name_path_pattern,
include_kinds=self._parse_kinds(include_kinds),
exclude_kinds=self._parse_kinds(exclude_kinds),
substring_matching=substring_matching,
within_relative_path=relative_path,
)
output_params = SymbolOutputParams(
kind=True,
name_path=True,
name=False,
relative_path=True,
body_location=True,
depth=depth,
include_body=include_body,
children_name=True,
children_name_path=False,
include_info=include_info,
)
renderer = LspSymbolCollectionRenderer(self._agent, max_answer_chars, output_params, grouper=self.find_symbol_dict_grouper_)
symbol_collection = LspSymbolCollection(symbols, renderer, self._request_info(symbol_retriever, symbols, output_params))
# check for max_matches limit exceeded
n_matches = len(symbols)
if 0 < max_matches < n_matches:
raise ValueError(
f"Matched {n_matches}>{max_matches=} symbols.\n" + TextOutputUtils.to_json(symbol_collection.relative_path_to_name_paths_())
)
return symbol_collection
@facade_method(uses_project_server=True, corresponding_tool=FindReferencingSymbolsTool)
def find_referencing_symbols(
self,
name_path: str,
relative_path: str,
include_kinds: Sequence[int] = (),
exclude_kinds: Sequence[int] = (),
max_answer_chars: int = -1,
) -> LspReferenceCollection:
"""
Finds references to the symbol at the given `name_path`.
The result will contain metadata about the referencing symbols as well as a short code snippet around the reference.
:param name_path: name path of the symbol
:param relative_path: the relative path to the file containing the symbol for which to find references.
:param include_kinds: (optional) limits results to the given LSP symbol kinds (integers, i.e. values of `SymbolKind`)
:param exclude_kinds: optional list of LSP symbol kinds (integers, i.e. values of `SymbolKind`) to exclude.
:return: the references to the symbol
"""
# file system sync needed for case where symbol finder does not perform a global search, updating everything
if relative_path:
self._get_project().ls_sync_file_system_changes()
symbol_retriever = self._create_symbol_retriever()
references = symbol_retriever.find_referencing_symbols(
name_path,
relative_file_path=relative_path,
include_body=False, # it is probably never a good idea to include the body of the referencing symbols
include_kinds=self._parse_kinds(include_kinds),
exclude_kinds=self._parse_kinds(exclude_kinds),
)
contents_around_references = [self._retrieve_content_around_reference(ref) for ref in references]
renderer = LspReferenceCollectionRenderer(self._agent, max_answer_chars, self.references_grouper_)
return LspReferenceCollection(references, contents_around_references, renderer)
@facade_method(uses_project_server=True, corresponding_tool=FindImplementationsTool)
def find_implementations(
self,
name_path: str,
relative_path: str,
include_info: bool = False,
include_kinds: Sequence[int] = (),
exclude_kinds: Sequence[int] = (),
max_answer_chars: int = -1,
) -> LspSymbolCollection:
"""
Finds implementations of the symbol at the given `name_path`.
:param name_path: the symbol's name path
:param relative_path: the relative path to the file containing the symbol for which to find implementations.
Note that here you can't pass a directory but must pass a file.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the implementing symbols.
:param include_kinds: (optional) limits results to the given LSP symbol kinds (integers, i.e. values of `SymbolKind`)
:param exclude_kinds: (optional) list of LSP symbol kinds (integers, i.e. values of `SymbolKind`) to exclude.
:return: the symbols implementing the given symbol
"""
self._get_project().ls_sync_file_system_changes()
symbol_retriever = self._create_symbol_retriever()
symbols = symbol_retriever.find_implementing_symbols(
name_path,
relative_file_path=relative_path,
include_body=False,
include_kinds=self._parse_kinds(include_kinds),
exclude_kinds=self._parse_kinds(exclude_kinds),
)
output_params = SymbolOutputParams(kind=True, relative_path=True, body_location=True, include_info=include_info)
renderer = LspSymbolCollectionRenderer(self._agent, max_answer_chars, output_params)
return LspSymbolCollection(symbols, renderer, self._request_info(symbol_retriever, symbols, output_params))
@facade_method(uses_project_server=True, corresponding_tool=FindDeclarationTool)
def find_declaration(
self,
relative_path: str,
regex: str,
containing_symbol_name_path: str | None = None,
include_body: bool = False,
include_info: bool = False,
) -> LspSymbol:
r"""
Finds the declaration of a symbol based on an occurrence of the symbol in a source file, specified by a regex.
:param relative_path: the relative path to the source file containing the symbol for which to find the declaration.
:param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup.
For example, to find the declaration of the `process` method in a call like `obj.process()`,
pass an expression like "obj\.(process)\(process_input_arg=37\)".
Prefer regexes with sufficiently large context around the group to render the match unambiguous.
Uses Python syntax with MULTILINE and DOTALL flags enabled.
:param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file.
:param include_body: whether to include the symbol's body in the result. Default False.
:param include_info: whether to include additional info (hover-like). Default False.
:return: the declaring symbol
"""
self._get_project().ls_sync_file_system_changes()
symbol_retriever = self._create_symbol_retriever()
# find relevant location for lookup
editor = self._create_ls_code_editor(symbol_retriever)
if not containing_symbol_name_path:
content = editor.read_file(relative_path)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
else:
symbol = symbol_retriever.find_unique(name_path_pattern=containing_symbol_name_path, within_relative_path=relative_path)
body_line_numbers = symbol.get_body_line_numbers_or_raise()
content = editor.read_file(relative_path, lines=body_line_numbers)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
coords.line += body_line_numbers[0]
# retrieve declaration
defining_symbol = symbol_retriever.find_declaration(
relative_file_path=relative_path, line=coords.line, column=coords.col, include_body=include_body
)
if defining_symbol is None:
raise ValueError(
f"No symbol declaration found at the location of the regex match. Location: {relative_path}:{coords.line}:{coords.col}."
)
output_params = SymbolOutputParams(
kind=True, relative_path=True, body_location=True, include_body=include_body, include_info=include_info
)
collection_renderer = LspSymbolCollectionRenderer(self._agent, -1, output_params)
info = self._request_info(symbol_retriever, [defining_symbol], output_params).get(defining_symbol)
return LspSymbol(defining_symbol, LspSymbolRenderer(self._agent, -1, collection_renderer), info)
@facade_method(uses_project_server=True, corresponding_tool=GetDiagnosticsForFileTool)
def get_diagnostics_for_file(
self, relative_path: str, start_line: int = 0, end_line: int = -1, min_severity: int = 4, max_answer_chars: int = -1
) -> LspDiagnostics:
"""
Gets diagnostics for a file.
Diagnostics are grouped as `relative_path -> severity -> name_path -> diagnostics_results`.
If a diagnostic cannot be mapped to a symbol, it is grouped under the special name path `<file>`.
:param relative_path: the relative path to the file to inspect.
:param start_line: the first 0-based line to include. Defaults to 0.
:param end_line: the last 0-based line to include. Defaults to -1, which means until the end of the file.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
Diagnostics with lower-or-equal numeric severity are returned.
:return: the grouped diagnostics for the requested file.
"""
self._get_project().ls_sync_file_system_changes()
symbol_retriever = self._create_symbol_retriever()
diagnostics = symbol_retriever.get_file_diagnostics(
relative_file_path=relative_path, start_line=start_line, end_line=end_line, min_severity=min_severity
)
grouped_diagnostics = GroupedDiagnostics()
for diagnostic in diagnostics:
diag_start = diagnostic["range"]["start"]
owner_symbol = symbol_retriever.find_diagnostic_owner_symbol(
relative_file_path=relative_path, line=diag_start["line"], column=diag_start["character"]
)
name_path = owner_symbol.get_name_path() if owner_symbol is not None else self.FILE_LEVEL_DIAGNOSTIC_BUCKET
grouped_diagnostics.add(relative_path, name_path, diagnostic)
return self._create_diagnostics(grouped_diagnostics, max_answer_chars)
@facade_method(uses_project_server=True, optional=True, corresponding_tool=GetDiagnosticsForSymbolTool)
def get_diagnostics_for_symbol(
self,
name_path: str,
reference_file: str = "",
check_symbol_references: bool = False,
min_severity: int = 4,
max_answer_chars: int = -1,
) -> LspDiagnostics:
"""
Gets diagnostics for the specified symbol.
When `check_symbol_references` is true, diagnostics for all referencing symbols are also included.
The result is grouped as `relative_path -> severity -> name_path -> diagnostics_results`.
:param name_path: the name path of the symbol to inspect.
:param reference_file: optional file path used to disambiguate the symbol search.
:param check_symbol_references: whether to additionally collect diagnostics for symbols that reference the symbol.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
Diagnostics with lower-or-equal numeric severity are returned.
:return: the grouped diagnostics for the requested symbol and, optionally, its referencing symbols.
"""
self._get_project().ls_sync_file_system_changes()
symbol_retriever = self._create_symbol_retriever()
diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics(
name_path=name_path,
reference_file=reference_file or None,
check_symbol_references=check_symbol_references,
min_severity=min_severity,
)
grouped_diagnostics = GroupedDiagnostics()
for symbol, diagnostics in diagnostics_by_symbol.items():
relative_path = symbol.relative_path
if relative_path is None:
continue
for diagnostic in diagnostics:
grouped_diagnostics.add(relative_path, symbol.get_name_path(), diagnostic)
return self._create_diagnostics(grouped_diagnostics, max_answer_chars)
# edit operations
@facade_method(uses_project_server=True, can_edit=True, corresponding_tool=RenameSymbolTool)
def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str:
"""
Renames the symbol with the given `name_path` to `new_name` throughout the entire codebase.
Note: for languages with method overloading, like Java, name_path may have to include a method's
signature to uniquely identify a method.
:param name_path: name path of the symbol to rename
:param relative_path: the relative path to the file containing the symbol to rename
:param new_name: the new name for the symbol
:return: a result summary indicating success or failure
"""
self._get_project().ls_sync_file_system_changes()
return self._create_ls_code_editor().rename_symbol(name_path, relative_path=relative_path, new_name=new_name)
@facade_method(uses_project_server=True, can_edit=True, corresponding_tool=SafeDeleteSymbol)
def safe_delete_symbol(self, name_path_pattern: str, relative_path: str) -> str:
"""
Deletes the symbol if it is safe to do so (i.e., if there are no references to it)
or returns a list of references to it.
:param name_path_pattern: name path of the symbol to delete
:param relative_path: the relative path to the file containing the symbol to delete
:return: a success message, or a message listing the references preventing deletion
"""
self._get_project().ls_sync_file_system_changes()
symbol_retriever = self._create_symbol_retriever()
symbol = symbol_retriever.find_unique(name_path_pattern, substring_matching=False, within_relative_path=relative_path)
symbol_rel_path = symbol.relative_path
assert symbol_rel_path is not None, f"Symbol {name_path_pattern} has no relative path, this is likely a bug."
assert symbol_rel_path == relative_path, f"Symbol {name_path_pattern} is not in the expected relative path {relative_path}."
symbol_name_path = symbol.get_name_path()
# check for references
symbol_line = symbol.line
symbol_col = symbol.column
assert symbol_line is not None and symbol_col is not None, (
f"Symbol {name_path_pattern} has no identifier position, this is likely a bug."
)
lang_server = symbol_retriever.get_language_server(symbol_rel_path)
references_locations = lang_server.request_references(symbol_rel_path, symbol_line, symbol_col)
file_to_lines: dict[str, list[int]] = defaultdict(list)
for ref_loc in references_locations or []:
ref_relative_path = ref_loc.get("relativePath")
if ref_relative_path is None:
continue
file_to_lines[ref_relative_path].append(ref_loc["range"]["start"]["line"])
if file_to_lines:
return f"Cannot delete, the symbol {symbol_name_path} is referenced in: {TextOutputUtils.to_json(file_to_lines)}"
self._create_ls_code_editor(symbol_retriever).delete_symbol(symbol_name_path, relative_file_path=symbol_rel_path)
return SUCCESS_RESULT
+181
View File
@@ -0,0 +1,181 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of memory operations (and onboarding, which creates the initial memories).
"""
import logging
import platform
from typing import TYPE_CHECKING, Literal
from serena.memories.memory_manager import MemoryManager
from serena.tools import (
DeleteMemoryTool,
EditMemoryTool,
ListMemoriesTool,
OnboardingTool,
ReadMemoryTool,
RenameMemoryTool,
WriteMemoryTool,
)
from ..facade import FacadeApi, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
log = logging.getLogger(__name__)
class MemoryList(RepresentableViaRenderer):
"""
The available memories: `memories` (writable) and `read_only_memories` (e.g. global memories), each a list of names.
"""
def __init__(self, memories_list: MemoryManager.MemoriesList, renderer: "MemoryListRenderer"):
"""
:param memories_list: the list of memories
:param renderer: the renderer to use for representing the list
"""
super().__init__(renderer)
self.memories_list_ = memories_list
@property
def memories(self) -> list[str]:
return sorted(self.memories_list_.memories)
@property
def read_only_memories(self) -> list[str]:
return sorted(self.memories_list_.read_only_memories)
class MemoryListRenderer(Renderer[MemoryList]):
def render(self, obj: MemoryList) -> str:
return self._limit_length(self._to_json(obj.memories_list_.to_dict()))
class MemoryApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(
agent,
name="mem",
description="project memories, i.e. persistent notes for future tasks",
)
def _get_memory_manager(self) -> MemoryManager:
return self._get_project().memory_manager
@facade_method(corresponding_tool=ListMemoriesTool)
def list_memories(self, topic: str = "") -> MemoryList:
"""
Lists the available memories, optionally filtered by topic.
:param topic: the topic (prefix of the memory name, e.g. "frontend") to restrict the listing to; empty for all memories
:return: the memories
"""
return MemoryList(self._get_memory_manager().list_memories(topic), MemoryListRenderer(self._agent, -1))
@facade_method(corresponding_tool=ReadMemoryTool)
def read_memory(self, memory_name: str) -> str:
"""
Reads a memory.
:param memory_name: the name of the memory
:return: the memory's content
"""
return self._get_memory_manager().load_memory(memory_name)
@facade_method(can_edit=True, corresponding_tool=WriteMemoryTool)
def write_memory(self, memory_name: str, content: str, max_chars: int = -1) -> str:
"""
Writes information (about the active project) to a memory.
The name should be meaningful and can include "/" to organize into topics.
If explicitly instructed, use the "global/" prefix for writing a memory that is shared across projects.
References to other memories should be inside backticks and prefixed with mem:,
e.g., `mem:auth`.
:param memory_name: the memory name
:param content: the memory content (utf-8-encoded markdown)
:param max_chars: the maximum content length; -1 for the configured default
:return: a message indicating the result
"""
if max_chars == -1:
max_chars = self._agent.serena_config.default_max_tool_answer_chars
if len(content) > max_chars:
raise ValueError(
f"Content for {memory_name} is too long. Max length is {max_chars} characters. Please make the content shorter."
)
return self._get_memory_manager().save_memory(memory_name, content, is_tool_context=True)
@facade_method(can_edit=True, corresponding_tool=EditMemoryTool)
def edit_memory(
self,
memory_name: str,
needle: str,
repl: str,
mode: Literal["literal", "regex"],
allow_multiple_occurrences: bool = False,
) -> str:
"""
Replaces content matching a pattern in a memory.
:param memory_name: the name of the memory
:param needle: the string or regex pattern to search for. In regex mode, be careful to not replace too much!
If `mode` is "literal", this string will be matched exactly.
If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module,
with the MULTILINE and DOTALL flags enabled).
:param repl: the replacement string (verbatim).
:param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted.
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
If false and multiple occurrences are found, an error will be raised.
:return: a message indicating the result
"""
return self._get_memory_manager().edit_memory(
memory_name, needle, repl, mode, allow_multiple_occurrences, is_tool_context=True, regex_multiline=True
)
@facade_method(can_edit=True, corresponding_tool=RenameMemoryTool)
def rename_memory(self, old_name: str, new_name: str) -> str:
"""
Renames or moves a memory; use "/" in the name to organize into topics.
The "global" topic should only be used if explicitly instructed.
References to other memories that are marked with the `mem:` prefix will be updated accordingly.
References in read-only memories are not affected.
:param old_name: the current name of the memory
:param new_name: the new name of the memory
:return: a message indicating the result
"""
renaming_message, n_references_updated = self._get_memory_manager().rename_memory_and_propagate_references(
old_name, new_name, is_tool_context=True
)
if n_references_updated > 0:
log.info(f"Updated {n_references_updated} references to memory {old_name} to {new_name}")
return renaming_message
@facade_method(can_edit=True, corresponding_tool=DeleteMemoryTool)
def delete_memory(self, memory_name: str) -> str:
"""
Deletes a memory; only call this if instructed explicitly or permission was granted by the user.
:param memory_name: the name of the memory
:return: a message indicating the result
"""
return self._get_memory_manager().delete_memory(memory_name, is_tool_context=True)
@facade_method(corresponding_tool=OnboardingTool)
def onboarding(self) -> str:
"""
Provides the instructions for performing onboarding (identifying the project structure and essential tasks,
e.g. for testing or building, and recording the findings in memories).
Call this if onboarding was not performed yet, at most once per conversation.
:return: the instructions on how to create the onboarding information
"""
# seed the project-local memory-maintenance memory (or detect a global override) so
# the prompt can point the agent at the conventions before it writes anything
memory_maintenance_name = self._get_memory_manager().ensure_memory_maintenance_memory()
return self._agent.prompt_factory.create_onboarding_prompt(
system=platform.system(), memory_maintenance_name=memory_maintenance_name
)
+88
View File
@@ -0,0 +1,88 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of shell command execution.
"""
import os.path
from typing import TYPE_CHECKING
from serena.tools import ExecuteShellCommandTool
from serena.util.shell import ShellCommandResult, execute_shell_command
from ..facade import FacadeApi, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class ShellCommandOutput(RepresentableViaRenderer):
"""
The outcome of a shell command: `stdout`, `stderr` (None if not captured), `return_code` and `cwd`.
"""
def __init__(self, result: ShellCommandResult, renderer: "ShellCommandOutputRenderer"):
"""
:param result: the result of the command execution
:param renderer: the renderer to use for representing the output
"""
super().__init__(renderer)
self.result_ = result
@property
def stdout(self) -> str:
return self.result_.stdout
@property
def stderr(self) -> str | None:
return self.result_.stderr
@property
def return_code(self) -> int:
return self.result_.return_code
@property
def cwd(self) -> str:
return self.result_.cwd
class ShellCommandOutputRenderer(Renderer[ShellCommandOutput]):
def render(self, obj: ShellCommandOutput) -> str:
return self._limit_length(obj.result_.model_dump_json())
class ShellApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(agent, name="shell", description="execution of shell commands")
@facade_method(can_edit=True, corresponding_tool=ExecuteShellCommandTool)
def execute_shell_command(
self, command: str, cwd: str | None = None, capture_stderr: bool = True, max_answer_chars: int = -1
) -> ShellCommandOutput:
"""
Executes a shell command and returns its output. If there is a memory about suggested commands, read that first.
Never execute unsafe shell commands!
IMPORTANT: Do not use this to start
* long-running processes (e.g. servers) that are not intended to terminate quickly,
* processes that require user interaction.
:param command: the shell command to execute
:param cwd: the working directory to execute the command in (absolute, or relative to the project root).
If None, the project root will be used.
:param capture_stderr: whether to capture and return stderr output
:return: the output (object with properties stdout, stderr, return_code and cwd)
"""
project_root = self._get_project().project_root
if cwd is None:
_cwd = project_root
elif os.path.isabs(cwd):
_cwd = cwd
else:
_cwd = os.path.join(project_root, cwd)
if not os.path.isdir(_cwd):
raise FileNotFoundError(
f"Specified a relative working directory ({cwd}), but the resulting path is not a directory: {_cwd}"
)
result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr)
return ShellCommandOutput(result, ShellCommandOutputRenderer(self._agent, max_answer_chars))
+67
View File
@@ -0,0 +1,67 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Execution of facade methods in the context of an external project (i.e. a project other than the active one).
"""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from serena.project_server import ProjectServerClient
from ..agent import SerenaAgent
from .facade import FacadeMethod
class ExternalProjectExecution:
"""
The context in which facade methods are executed while an external project is being queried:
methods which use the project server (see `FacadeMethodInfo.uses_project_server`) are executed remotely
in the project server (if remote execution applies to the language backend), all other methods are executed
locally against the temporarily switched project. Editing methods are not permitted.
"""
def __init__(self, project_name: str, read_only: bool, agent: "SerenaAgent") -> None:
"""
:param project_name: the name of the external project
:param read_only: whether the external project is to be treated as read-only (editing methods are not permitted)
"""
self.project_name = project_name
self._client: ProjectServerClient | None = None
self._read_only = read_only
self._agent = agent
def is_called_remotely(self, method: "FacadeMethod") -> bool:
"""
:param method: the method to check
:return: whether the given method must be executed remotely
"""
# Any method that uses the project server must be executed remotely,
# as does any edit operation when using the LSP backend (as edit operations indirectly
# use the language server via the CodeEditor abstraction)
return method.info.uses_project_server or (self._agent.get_language_backend().is_lsp() and method.info.can_edit)
def check_call_permission(self, method: "FacadeMethod") -> None:
"""
Checks whether the given method is permitted to be called in the context of this external project execution.
Raises an exception if the method is not permitted.
:param method: the facade method to check
"""
if self._read_only and method.info.can_edit:
raise PermissionError(f"Editing methods are not permitted in read-only external project execution: {method.qualified_name}")
def call_remotely(self, facade_name: str, method_name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
"""
Executes the given facade method remotely via the project server.
:param facade_name: the facade's name
:param method_name: the method's name
:param args: the positional arguments (must be JSON-serialisable)
:param kwargs: the keyword arguments (must be JSON-serialisable)
:return: the method's result (unpickled)
"""
if self._client is None:
from serena.project_server import ProjectServerClient
self._client = ProjectServerClient(self._agent.serena_config)
return self._client.call_facade_method(self.project_name, facade_name, method_name, list(args), kwargs)
+747
View File
@@ -0,0 +1,747 @@
"""
The facade, i.e. the object through which REPL code accesses a group of related operations.
"""
# SPDX-License-Identifier: GPL-3.0-or-later
import inspect
import logging
import re
import typing
from abc import ABC
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, TypeVar
import typing_extensions
from serena.config.serena_config import ApiInclusionDefinition
from serena.project import Project
from .representable import RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor
from serena.tools import Tool
from .external_project import ExternalProjectExecution
log = logging.getLogger(__name__)
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
SUCCESS_RESULT = "OK"
"""the result returned by operations which have no result other than their success"""
def format_annotation(annotation: Any) -> str:
"""
:param annotation: a type annotation (or a signature/annotation string)
:return: the annotation rendered without module paths (e.g. `list[LanguageServerSymbol]`), such that type names
match the names by which the types can be looked up
"""
text = annotation if isinstance(annotation, str) else inspect.formatannotation(annotation)
return re.sub(r"\b(?:[A-Za-z_]\w*\.)+([A-Za-z_]\w*)", r"\1", text)
def format_signature(callable_: Callable[..., Any]) -> str:
"""
:param callable_: the callable
:return: the signature rendered without module paths in annotations
"""
return format_annotation(str(inspect.signature(callable_)))
def extract_referenced_classes(annotation: Any) -> list[type]:
"""
:param annotation: a (resolved) type annotation
:return: the user-defined classes appearing in the annotation (recursively, e.g. in `list[X] | None`), in order of
appearance; builtins, typing constructs and classes from the standard library are excluded
"""
classes: list[type] = []
def visit(a: Any) -> None:
if isinstance(a, type):
module = getattr(a, "__module__", "")
if module not in ("builtins", "typing", "collections.abc", "abc") and not module.startswith("_") and a not in classes:
classes.append(a)
for arg in typing.get_args(a):
visit(arg)
visit(annotation)
return classes
def get_annotated_classes(callable_: Callable[..., Any]) -> list[type]:
"""
:param callable_: a function or method
:return: the user-defined classes appearing in the annotations of its parameters and return type
"""
try:
hints = typing.get_type_hints(callable_)
except Exception: # unresolvable forward references
return []
classes: list[type] = []
for hint in hints.values():
classes.extend(c for c in extract_referenced_classes(hint) if c not in classes)
return classes
@dataclass
class ReferencedType:
"""
A type that is referenced by a facade's methods (returned by them, contained in their results or used as a parameter
type), whose interface the LLM can inspect via `info`.
All types reachable through annotations are discovered automatically; an explicit declaration is needed only in order
to curate the type's presentation (the members to describe, inclusion in the facade's description).
"""
cls: type
"""the type"""
provide_info_with_facade: bool = False
"""whether the type's full description is included in the facade's description (rather than just its name)"""
members: Sequence[str] | None = None
"""
the LLM-facing members (attributes, properties, methods) to describe; if None, all members admitted by the naming
convention (no leading or trailing underscore) which are documented are described. Explicitly listed methods are
described even if undocumented, as the listing is the documentation decision.
"""
_CAPABILITIES: typing.ClassVar[dict[str, str]] = {
"__len__": "len()",
"__iter__": "iteration",
"__getitem__": "indexing",
"__enter__": "use in a `with` statement",
}
@property
def name(self) -> str:
return self.cls.__name__
def _get_member_names(self) -> list[str]:
if self.members is not None:
return list(self.members)
names = set(typing.get_type_hints(self.cls))
names.update(n for n in dir(self.cls) if not n.startswith("_"))
# exclude the representation mechanism, which is not meant to be used from REPL code
names.difference_update(dir(RepresentableViaRenderer))
return sorted(n for n in names if not n.startswith("_") and not n.endswith("_"))
def get_referenced_classes(self) -> list[type]:
"""
:return: the user-defined classes appearing in the annotations of the described members (attributes, properties,
method parameters and return types), in order of appearance (each class at most once, excluding the type itself)
"""
if self.is_enum():
return []
classes: list[type] = []
type_hints = typing.get_type_hints(self.cls)
if self.is_typed_dict():
for hint in type_hints.values():
classes.extend(c for c in extract_referenced_classes(hint) if c is not self.cls and c not in classes)
return classes
for member_name in self._get_member_names():
member = inspect.getattr_static(self.cls, member_name, None)
if isinstance(member, property) and member.fget is not None:
found = get_annotated_classes(member.fget)
elif inspect.isfunction(member):
found = get_annotated_classes(member)
elif member_name in type_hints:
found = extract_referenced_classes(type_hints[member_name])
else:
found = []
classes.extend(c for c in found if c is not self.cls and c not in classes)
return classes
def is_enum(self) -> bool:
return isinstance(self.cls, type) and issubclass(self.cls, Enum)
def is_typed_dict(self) -> bool:
# NOTE: TypedDicts defined via typing_extensions are not recognised by typing.is_typeddict
return typing.is_typeddict(self.cls) or typing_extensions.is_typeddict(self.cls)
def _describe_typed_dict(self) -> str:
parts = [f"type {self.name} (a dict with the following keys)"]
if self.cls.__doc__ and not self.cls.__doc__.startswith(self.name + "("): # NOTE: the default docstring is uninformative
parts.append(f" {inspect.cleandoc(self.cls.__doc__).replace(chr(10), chr(10) + ' ')}")
type_hints = typing.get_type_hints(self.cls)
member_names = self.members if self.members is not None else list(type_hints)
parts.append(
"keys:\n" + "\n".join(f" {name}: {format_annotation(type_hints[name])}" for name in member_names if name in type_hints)
)
return "\n".join(parts) + "\n"
def _describe_enum(self) -> str:
parts = [f"enum {self.name}"]
if self.cls.__doc__:
parts.append(f" {inspect.cleandoc(self.cls.__doc__).replace(chr(10), chr(10) + ' ')}")
parts.append("members:\n" + "\n".join(f" {self.name}.{member.name} = {member.value!r}" for member in self.cls)) # type: ignore[attr-defined]
return "\n".join(parts) + "\n"
@staticmethod
def _first_doc_line(obj: Any) -> str:
doc = inspect.getdoc(obj) or ""
first_line = doc.splitlines()[0] if doc else ""
return first_line.removeprefix(":return:").strip()
def describe(self) -> str:
"""
:return: the type's documentation: its docstring, attributes/properties with their types and methods with their
signatures and documentation; for enums, the members with their values
"""
if self.is_enum():
return self._describe_enum()
if self.is_typed_dict():
return self._describe_typed_dict()
attributes: list[str] = []
methods: list[str] = []
type_hints = typing.get_type_hints(self.cls)
for member_name in self._get_member_names():
member = inspect.getattr_static(self.cls, member_name, None)
if isinstance(member, property):
fget = member.fget
annotation = inspect.signature(fget).return_annotation if fget is not None else inspect.Signature.empty
type_str = f": {format_annotation(annotation)}" if annotation is not inspect.Signature.empty else ""
doc = self._first_doc_line(member)
attributes.append(f" {member_name}{type_str}" + (f" # {doc}" if doc else ""))
elif inspect.isfunction(member):
doc = inspect.getdoc(member)
if doc is None and self.members is None:
continue
signature = format_signature(member).replace("(self, ", "(", 1).replace("(self)", "()", 1)
methods.append(f" {member_name}{signature}" + (f"\n {doc.replace(chr(10), chr(10) + ' ')}" if doc else ""))
elif member_name in type_hints:
attributes.append(f" {member_name}: {format_annotation(type_hints[member_name])}")
else:
attributes.append(f" {member_name}")
# assemble the description
parts = [f"type {self.name}"]
if self.cls.__doc__: # NOTE: the class' own docstring (inspect.getdoc would fall back to base class docstrings)
doc = inspect.cleandoc(self.cls.__doc__)
parts.append(f" {doc.replace(chr(10), chr(10) + ' ')}")
if attributes:
parts.append("attributes:\n" + "\n".join(attributes))
if methods:
parts.append("methods:\n" + "\n".join(methods))
capabilities = [text for dunder, text in self._CAPABILITIES.items() if dunder in dir(self.cls) and dunder not in dir(object)]
if capabilities:
parts.append("supports: " + ", ".join(capabilities))
return "\n".join(parts) + "\n"
@dataclass(kw_only=True, frozen=True)
class FacadeMethodInfo:
"""
The metadata of a method exposed through a facade (see `facade_method`), mirroring the tool markers.
"""
name: str
"""the name of the method"""
optional: bool = False
"""whether the method is disabled by default and must be enabled explicitly"""
beta: bool = False
"""whether the method is in beta (not yet fully stable)"""
can_edit: bool = False
"""whether the method can modify the codebase (relevant for read-only contexts)"""
niche: bool = False
"""
whether the method is rarely needed, such that the facade's description only summarises it (first line of its
documentation and a pointer to its full documentation) in order to keep the description compact
"""
uses_project_server: bool = False
"""
whether the method requires the project's language servers and must therefore be executed in the project server
when an external project is queried (see `ExternalProjectContext`).
Edit operations are always executed in the project server, regardless of this flag, since they implicitly
use the CodeEditor, which requires language servers when using the LSP backend.
Polymorphic edit operations therefore must not set this flag to True.
"""
corresponding_tool: "type[Tool] | None" = None
"""the classic tool offering the same functionality, if any"""
def get_corresponding_tool_name(self) -> str | None:
"""
:return: the name of the corresponding tool, or None if there is none
"""
return self.corresponding_tool.get_name_from_cls() if self.corresponding_tool is not None else None
_FACADE_METHOD_INFO_ATTR = "__facade_method_info__"
def facade_method(
*,
optional: bool = False,
beta: bool = False,
can_edit: bool = False,
niche: bool = False,
uses_project_server: bool = False,
corresponding_tool: "type[Tool] | None" = None,
) -> Callable[[TCallable], TCallable]:
"""
Marks a method of a `FacadeApi` as exposed through the facade, attaching the given metadata.
The decorator only annotates the method (it does not wrap it), such that signature and docstring remain intact.
:param optional: whether the method is disabled by default and must be enabled explicitly
:param beta: whether the method is in beta
:param can_edit: whether the method can modify the codebase
:param niche: whether the method is rarely needed (its documentation is then only summarised in the facade's description)
:param uses_project_server: whether the method must be executed remotely in the project server when an external project is queried
:param corresponding_tool: the classic tool offering the same functionality, if any
:return: the decorator
"""
def decorator(method: TCallable) -> TCallable:
info = FacadeMethodInfo(
name=method.__name__,
optional=optional,
beta=beta,
can_edit=can_edit,
niche=niche,
uses_project_server=uses_project_server,
corresponding_tool=corresponding_tool,
)
setattr(method, _FACADE_METHOD_INFO_ATTR, info)
return method
return decorator
def get_facade_method_info(method: Callable[..., Any]) -> FacadeMethodInfo | None:
"""
:param method: a (bound or unbound) method
:return: the metadata attached via `facade_method`, or None if the method is not exposed
"""
return getattr(method, _FACADE_METHOD_INFO_ATTR, None)
class FacadeApi(ABC):
"""
The implementation of a facade's functionality.
API design principles:
* A method is exposed to the LLM if and only if it is decorated with `facade_method`, which also carries
the method's metadata (optional, beta, can_edit). Undecorated methods are never exposed, regardless of their name.
* On the objects returned by API methods (which are not decorated), the name determines visibility:
names with a trailing underscore (e.g. `symbols_`, `to_dict_`) are public within Serena (e.g. for use by
classic tools or other facade implementations) but are not meant to be called from REPL code, whereas
names without leading or trailing underscore constitute the LLM-facing interface.
The same convention applies to non-exposed helper methods of API classes.
* Names with a leading underscore are private, as usual.
"""
def __init__(self, agent: "SerenaAgent", name: str, description: str, types: Sequence[ReferencedType] = ()) -> None:
"""
:param agent: the agent providing access to the project and its resources
:param name: the attribute name under which the facade is accessible from the REPL entrypoint
:param description: a one-line description of the functionality offered by the facade
:param types: declarations for referenced types whose presentation is to be curated (see `ReferencedType`);
types reachable through annotations need not be declared in order to be documentable
"""
self._agent = agent
self._name = name
self._description = description
self._types = list(types)
def get_name_(self) -> str:
return self._name
def get_description_(self) -> str:
return self._description
def get_referenced_types_(self) -> list[ReferencedType]:
return self._types
def _get_project(self) -> Project:
return self._agent.get_active_project_or_raise()
def _create_code_editor(self) -> "CodeEditor":
"""
:return: a code editor for the active project, using the active language backend
"""
project = self._get_project()
backend = self._agent.get_language_backend()
return backend.create_code_editor(project)
class FacadeMethod:
"""
A method of a facade, which delegates to a method of the underlying implementation and which can be
enabled or disabled; only enabled methods are accessible from REPL code.
"""
def __init__(self, parent: "Facade", implementation: Callable[..., Any], info: FacadeMethodInfo, enabled: bool) -> None:
"""
:param parent: the facade the method belongs to
:param implementation: the implementation to delegate to
:param info: the method's metadata (including the method's name)
:param enabled: whether the method is initially enabled
"""
self.parent = parent
self._implementation = implementation
self.info = info
self.enabled = enabled
@property
def name(self) -> str:
return self.info.name
@property
def facade_name(self) -> str:
return self.parent.name
@property
def qualified_name(self) -> str:
"""
:return: the name under which the method is accessible from REPL code (facade name and method name)
"""
return f"{self.facade_name}.{self.name}"
def __call__(self, *args: Any, **kwargs: Any) -> Any:
external_project_execution = self.parent.get_external_project_()
if external_project_execution is not None:
external_project_execution.check_call_permission(self)
if external_project_execution.is_called_remotely(self):
return external_project_execution.call_remotely(self.facade_name, self.name, args, kwargs)
return self._implementation(*args, **kwargs)
def get_implementation_(self) -> Callable[..., Any]:
return self._implementation
def get_referenced_return_types(self) -> list[ReferencedType]:
"""
:return: the types referenced by the facade which appear in the method's return type annotation
"""
return_annotation = format_annotation(inspect.signature(self._implementation).return_annotation)
return self._find_referenced_types(return_annotation)
def get_referenced_parameter_types(self) -> list[ReferencedType]:
"""
:return: the types referenced by the facade which appear in the annotations of the method's parameters
"""
parameters = inspect.signature(self._implementation).parameters.values()
return self._find_referenced_types(" ".join(format_annotation(p.annotation) for p in parameters))
def _find_referenced_types(self, annotation_text: str) -> list[ReferencedType]:
return [t for t in self.parent.get_types() if re.search(rf"\b{re.escape(t.name)}\b", annotation_text)]
def get_summary(self) -> str:
"""
:return: the first line of the method's documentation
"""
doc = inspect.getdoc(self._implementation)
return doc.splitlines()[0] if doc else "(no documentation)"
def describe(self) -> str:
"""
:return: the method's signature and documentation, with pointers to the documentation of the referenced types
appearing in its return type and parameter annotations
"""
signature = format_signature(self._implementation)
doc = inspect.getdoc(self._implementation) or "(no documentation)"
text = f"{self.qualified_name}{signature}\n{doc}\n"
referenced = self.get_referenced_return_types()
referenced += [t for t in self.get_referenced_parameter_types() if t not in referenced]
if referenced:
pointers = ", ".join(f'`s.info("{t.name}")`' for t in referenced)
text += f"Type documentation: {pointers}\n"
return text
def describe_summary(self) -> str:
"""
:return: the method's name and the first line of its documentation, with a pointer to its full documentation
"""
return f'{self.qualified_name}: {self.get_summary()} [full documentation: `s.info("{self.qualified_name}")`]\n'
class ApiScope:
"""
The scope of APIs available to the LLM, i.e. which facade methods are enabled, as determined by
applying a sequence of inclusion/exclusion definitions (from the global configuration, the context,
the active modes and the project configuration) to the methods' default enablement.
"""
class FacadeScope:
"""
The scope of a single facade: whether the facade as a whole is included, and which of its methods
were explicitly included/excluded (a method is never in both sets).
If the facade is not included, it is opt-in, i.e. only explicitly included methods are enabled.
"""
def __init__(self) -> None:
self._is_included: bool | None = None
self.method_inclusions: set[str] = set()
self.method_exclusions: set[str] = set()
def exclude_facade(self) -> None:
self._is_included = False
self.method_inclusions = set()
self.method_exclusions = set()
def include_facade(self) -> None:
self._is_included = True
def is_facade_included(self, is_facade_optional: bool) -> bool:
if is_facade_optional:
return self._is_included is True
else:
return self._is_included is not False
def exclude_method(self, method_name: str) -> None:
self.method_inclusions.discard(method_name)
self.method_exclusions.add(method_name)
def include_method(self, method_name: str) -> None:
self.method_exclusions.discard(method_name)
self.method_inclusions.add(method_name)
def __init__(self) -> None:
self._facade_scopes: dict[str, ApiScope.FacadeScope] = {}
self._editing_excluded = False
def _get_facade_scope(self, facade_name: str) -> "ApiScope.FacadeScope":
if facade_name not in self._facade_scopes:
self._facade_scopes[facade_name] = ApiScope.FacadeScope()
return self._facade_scopes[facade_name]
def process(self, definition: ApiInclusionDefinition) -> None:
"""
Applies the given definition, exclusions first, then inclusions (such that inclusions take precedence
within a definition; across definitions, later definitions take precedence).
:param definition: the definition to apply
"""
def apply(api_ref: str, *, excluded: bool) -> None:
components = api_ref.split(".")
if len(components) > 2:
log.warning("Ignoring invalid API reference '%s' in %s (expected 'facade' or 'facade.method')", api_ref, definition)
return
facade_scope = self._get_facade_scope(components[0])
if len(components) == 1:
facade_scope.exclude_facade() if excluded else facade_scope.include_facade()
else:
facade_scope.exclude_method(components[1]) if excluded else facade_scope.include_method(components[1])
for api_exclusion in definition.excluded_apis:
apply(api_exclusion, excluded=True)
for api_inclusion in definition.included_apis:
apply(api_inclusion, excluded=False)
def exclude_editing(self) -> None:
"""
Excludes all methods which can edit the codebase (read-only operation), regardless of other inclusions.
"""
self._editing_excluded = True
def is_method_enabled(self, facade_name: str, method_info: FacadeMethodInfo, is_facade_optional: bool) -> bool:
"""
:param facade_name: the name of the facade
:param method_info: the method's metadata
:param is_facade_optional: whether the facade is optional (disabled by default and must be enabled explicitly)
:return: whether the method is enabled: optional methods (and all methods of a facade which is not included,
i.e. an excluded facade or an optional facade that was not explicitly included) must be explicitly
included, other methods are enabled unless explicitly excluded; if editing is excluded, editing
methods are always disabled
"""
if self._editing_excluded and method_info.can_edit:
return False
facade_scope = self._get_facade_scope(facade_name)
# A method that would be disabled because the facade it is part of is not included
# or the method itself is optional must be explicitly included in order to be enabled.
if not facade_scope.is_facade_included(is_facade_optional) or method_info.optional:
return method_info.name in facade_scope.method_inclusions
# A method that is not optional and whose facade is included is enabled unless it is explicitly excluded.
else:
return method_info.name not in facade_scope.method_exclusions
class Facade:
"""
A named group of related operations which an LLM can invoke from REPL code.
"""
def __init__(self, name: str, description: str, is_optional: bool = False, types: Sequence[ReferencedType] = ()) -> None:
# NOTE: attributes are set via object.__setattr__ because __getattr__ is overridden
object.__setattr__(self, "_is_optional", is_optional)
object.__setattr__(self, "_name", name)
object.__setattr__(self, "_description", description)
object.__setattr__(self, "_methods", {})
object.__setattr__(self, "_types", {t.name: t for t in types})
object.__setattr__(self, "_external_project", None)
def set_external_project_(self, external_project: "ExternalProjectExecution | None") -> None:
"""
:param external_project: the context of the external project being queried (None if the active project is used)
"""
object.__setattr__(self, "_external_project", external_project)
def get_external_project_(self) -> "ExternalProjectExecution | None":
return self._external_project
def _add_method(self, method: FacadeMethod) -> None:
assert method.parent is self
self._methods[method.name] = method
@staticmethod
def from_api(api: FacadeApi, api_scope: ApiScope, *, is_optional: bool = False) -> "Facade":
"""
Creates a facade wrapping the given implementation.
:param api: the implementation; each of its methods decorated with `facade_method` becomes a facade method
:param api_scope: API scope definition determining which methods are enabled
:param is_optional: whether the facade is optional (disabled by default and must be enabled explicitly)
:return: the facade
"""
facade = Facade(api.get_name_(), api.get_description_(), is_optional, api.get_referenced_types_())
for name, member in inspect.getmembers(api, predicate=inspect.ismethod):
method_info = get_facade_method_info(member)
if method_info is None:
continue
is_enabled = api_scope.is_method_enabled(facade.name, method_info, is_facade_optional=is_optional)
facade._add_method(FacadeMethod(facade, member, method_info, enabled=is_enabled))
facade._discover_referenced_types()
return facade
def is_enabled(self) -> bool:
"""
:return: whether the facade is enabled
"""
return len(self.get_enabled_methods()) > 0
def is_optional(self) -> bool:
"""
:return: whether the facade is optional, i.e. disabled unless it is explicitly included
"""
return self._is_optional
def _discover_referenced_types(self) -> None:
"""
Adds referenced types for all classes reachable (transitively) through the annotations of the facade's methods
and of the referenced types' members, such that every type an LLM may encounter can be documented.
Explicitly declared types take precedence (they may curate members and carry flags).
"""
# seed the worklist with the classes referenced by the declared types and by the methods
pending: list[type] = []
for referenced_type in self._types.values():
pending.extend(referenced_type.get_referenced_classes())
for method in self._methods.values():
pending.extend(get_annotated_classes(method.get_implementation_()))
# add undeclared classes, following their references in turn
while pending:
cls = pending.pop(0)
if cls.__name__ in self._types:
continue
referenced_type = ReferencedType(cls)
self._types[cls.__name__] = referenced_type
pending.extend(referenced_type.get_referenced_classes())
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def enabled_method_names(self) -> list[str]:
return [m.name for m in self._methods.values() if m.enabled]
def get_enabled_methods(self) -> list[FacadeMethod]:
"""
:return: the list of enabled methods
"""
return [m for m in self._methods.values() if m.enabled]
def get_methods(self) -> list[FacadeMethod]:
"""
:return: the list of all methods, regardless of whether they are enabled (e.g. for changing their enabled state)
"""
return list(self._methods.values())
def get_types(self) -> list[ReferencedType]:
"""
:return: the types referenced by the facade's methods
"""
return list(self._types.values())
def get_type(self, type_name: str) -> ReferencedType | None:
"""
:param type_name: the name of the type
:return: the referenced type, or None if the facade does not reference a type of that name
"""
return self._types.get(type_name)
def get_method(self, method_name: str) -> FacadeMethod:
"""
:param method_name: the name of the method
:return: the method, regardless of whether it is enabled (e.g. for changing its enabled state)
"""
if method_name not in self._methods:
raise ValueError(f"Facade '{self._name}' has no method '{method_name}'")
return self._methods[method_name]
def _get_enabled_method(self, name: str) -> FacadeMethod | None:
method = self._methods.get(name)
return method if method is not None and method.enabled else None
def _no_such_method_message(self, name: str) -> str:
return f"Facade '{self._name}' has no method '{name}'. Available methods: {self.enabled_method_names}"
def __getattr__(self, name: str) -> Any:
# delegate attribute access to enabled methods only (called only if regular attribute lookup fails)
method = self._get_enabled_method(name)
if method is None:
raise AttributeError(self._no_such_method_message(name))
return method
def __setattr__(self, name: str, value: Any) -> None:
raise AttributeError(f"Facade '{self._name}' is read-only")
def describe(self) -> str:
"""
:return: a description of the facade listing all of its enabled methods with their signatures and documentation
as well as its referenced types (in full if so declared, otherwise by name)
"""
parts = [f"Facade '{self._name}': {self._description}", ""]
enabled_methods = self.get_enabled_methods()
for method in enabled_methods:
if not method.info.niche:
parts.append(method.describe())
niche_methods = [m for m in enabled_methods if m.info.niche]
if niche_methods:
parts.append("Rarely needed methods (documented on request):\n" + "".join(m.describe_summary() for m in niche_methods))
for referenced_type in self._types.values():
if referenced_type.provide_info_with_facade:
parts.append(referenced_type.describe())
result_type_names = sorted(
{t.name for m in enabled_methods for t in m.get_referenced_return_types() if not t.provide_info_with_facade}
)
if result_type_names:
parts.append(
"Result types: "
+ ", ".join(result_type_names)
+ ' (request documentation via `s.info("<type name>")` only if you intend to process results in code)'
)
return "\n".join(parts)
def describe_member(self, member_name: str) -> str:
"""
:param member_name: the name of one of the facade's enabled methods or referenced types
:return: the member's documentation
"""
method = self._get_enabled_method(member_name)
if method is not None:
return method.describe()
referenced_type = self._types.get(member_name)
if referenced_type is not None:
return referenced_type.describe()
raise ValueError(
f"Facade '{self._name}' has no method or type '{member_name}'. "
f"Available methods: {self.enabled_method_names}; types: {list(self._types)}"
)
+384
View File
@@ -0,0 +1,384 @@
"""
The REPL through which an LLM executes Python code against Serena's facades.
"""
# SPDX-License-Identifier: GPL-3.0-or-later
import ast
import logging
import re
import traceback
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from ..session import SerenaSession
from .external_project import ExternalProjectExecution
from .facade import ApiScope, Facade, FacadeMethod, ReferencedType
from .representable import Representable
if TYPE_CHECKING:
from ..tools.tools_base import Tool
log = logging.getLogger(__name__)
class FacadeAvailabilityInfo:
"""
Represents information on the availability of facades and the methods therein
"""
@dataclass
class FacadeInfo:
name: str
is_enabled: bool
methods: list["FacadeAvailabilityInfo.MethodInfo"]
@dataclass
class MethodInfo:
name: str
is_enabled: bool
def __init__(self):
self.facades: list[FacadeAvailabilityInfo.FacadeInfo] = []
def add_facade(self, facade: Facade):
methods_info = [FacadeAvailabilityInfo.MethodInfo(name=method.name, is_enabled=method.enabled) for method in facade.get_methods()]
self.facades.append(FacadeAvailabilityInfo.FacadeInfo(name=facade.name, is_enabled=facade.is_enabled(), methods=methods_info))
class SerenaReplEntrypoint:
"""
Represents the entrypoint object for the REPL. It holds the configured facades as attributes
and offers progressive disclosure of their interfaces via `info`.
"""
def __init__(self, facades: list[Facade], api_scope: ApiScope) -> None:
"""
:param facades: the candidate facades
:param api_scope: the API scope, which determines which of the facades are made available
"""
self._facades: dict[str, Facade] = {}
self._current_session: SerenaSession | None = None
self._current_namespace: dict[str, Any] | None = None
self._facade_availability_info = FacadeAvailabilityInfo()
registered_facade_names = []
for facade in facades:
self._facade_availability_info.add_facade(facade)
if facade.is_enabled():
if facade.name in self._facades:
raise ValueError(f"Duplicate facade name: {facade.name}")
self._facades[facade.name] = facade
setattr(self, facade.name, facade)
registered_facade_names.append(facade.name)
log.info("Registered %d/%d facades: %s", len(registered_facade_names), len(facades), registered_facade_names)
def get_facade_availability_info(self) -> FacadeAvailabilityInfo:
"""
:return: the availability of all facades and their methods (enabled or disabled)
"""
return self._facade_availability_info
def get_enabled_methods(self) -> list[FacadeMethod]:
"""
:return: the list of all enabled methods across all facades
"""
return [method for facade in self._facades.values() for method in facade.get_enabled_methods()]
def is_tool_function_available(self, tool_class: "type[Tool]") -> bool:
"""
Checks whether any of the enabled methods corresponds to the given tool class.
:param tool_class: the tool class to check for
:return: whether any enabled method corresponds to the given tool class
"""
for method in self.get_enabled_methods():
if method.info.corresponding_tool == tool_class:
return True
return False
def set_external_project_(self, external_project: "ExternalProjectExecution | None") -> None:
"""
:param external_project: the context of the external project being queried by the currently executing code
(None if the active project is used); propagated to all facades
"""
for facade in self._facades.values():
facade.set_external_project_(external_project)
def get_external_project_(self) -> "ExternalProjectExecution | None":
external_projects = {facade.get_external_project_() for facade in self._facades.values()}
return next(iter(external_projects)) if external_projects else None
def set_current_session_(self, session: SerenaSession | None, namespace: dict[str, Any] | None) -> None:
"""
:param session: the session on whose behalf code is being executed (None if no code is being executed)
:param namespace: the namespace of the execution (None if no code is being executed)
"""
self._current_session = session
self._current_namespace = namespace
def _get_persisted_items(self) -> dict[str, Any]:
assert self._current_namespace is not None, "No code execution in progress"
return {
name: value
for name, value in self._current_namespace.items()
if name != SerenaRepl.ENTRYPOINT_NAME and SerenaRepl.is_persisted_name(name)
}
def vars(self) -> str:
"""
Lists the variables and functions which persist in the session's namespace across executions.
:return: the listing (name, type and a short representation per item)
"""
items = self._get_persisted_items()
if not items:
return "No persisted variables."
lines = []
for name, value in items.items():
summary = value.__name__ if callable(value) and hasattr(value, "__name__") else repr(value)
if len(summary) > 80:
summary = summary[:77] + "..."
lines.append(f"{name}: {type(value).__name__} = {summary}")
return "\n".join(lines)
def clear(self) -> str:
"""
Removes all persisted variables and functions from the session's namespace.
:return: a message indicating the number of removed items
"""
items = self._get_persisted_items()
assert self._current_namespace is not None
for name in items:
del self._current_namespace[name]
return f"Removed {len(items)} persisted item(s)."
def _get_facade(self, name: str) -> Facade:
if name not in self._facades:
raise ValueError(f"Unknown facade '{name}'. Available facades: {list(self._facades)}")
return self._facades[name]
def get_facade_(self, name: str) -> Facade:
"""
:param name: the facade's name
:return: the facade
"""
return self._get_facade(name)
def overview(self) -> str:
"""
:return: the list of available facades, each with a one-line description and the names of its methods
(with the result type of methods returning objects that can be processed in code)
"""
def method_entry(method: FacadeMethod) -> str:
return_types = method.get_referenced_return_types()
return method.name + (f" -> {'|'.join(t.name for t in return_types)}" if return_types else "")
return "\n".join(
f"s.{facade.name}: {facade.description}\n methods: {', '.join(method_entry(m) for m in facade.get_enabled_methods())}"
for facade in self._facades.values()
)
def info(self, *items: str) -> str:
"""
Provides documentation on the available functionality.
:param items: the items to document; if none are given, an overview of all facades is provided.
Each item is either a facade name (e.g. "lsp") for the documentation of all of the facade's methods and types,
a dotted path (e.g. "lsp.find_symbol" or "lsp.LspSymbolCollection") for the documentation of a single method
or type, or a bare type name (e.g. "LanguageServerSymbol"), which is looked up across all facades.
Unknown items are reported without affecting the documentation of the other items.
:return: the requested documentation
"""
if not items:
return self.overview()
described_in_call: set[str] = set()
return "\n\n".join(self._describe_item(item, described_in_call) for item in items)
def _describe_item(self, item: str, described_in_call: set[str]) -> str:
facade_name, _, member_name = item.partition(".")
try:
if member_name:
facade = self._get_facade(facade_name)
referenced_type = facade.get_type(member_name)
if referenced_type is not None:
return self._describe_type(referenced_type, described_in_call)
return facade.describe_member(member_name)
if facade_name in self._facades:
return self._facades[facade_name].describe()
# not a facade: look up the item as a type across all facades
referenced_type = self._find_type(item)
if referenced_type is None:
raise ValueError(f"Unknown item '{item}': neither a facade nor a type. Available facades: {list(self._facades)}")
return self._describe_type(referenced_type, described_in_call)
except ValueError as e:
return str(e)
def _find_type(self, type_name: str) -> ReferencedType | None:
for facade in self._facades.values():
referenced_type = facade.get_type(type_name)
if referenced_type is not None:
return referenced_type
return None
def _describe_type(self, referenced_type: ReferencedType, described_in_call: set[str]) -> str:
"""
Describes the given (explicitly requested) type along with the types it references (transitively), each at most
once per call. Referenced types whose documentation was already provided earlier in the session are not repeated
but pointed to (an explicit request always yields the full documentation).
:param referenced_type: the requested type
:param described_in_call: the names of the types already described in the current `info` call (updated)
:return: the documentation
"""
session = self._current_session
parts = []
if referenced_type.name not in described_in_call:
parts.append(referenced_type.describe())
described_in_call.add(referenced_type.name)
if session is not None:
session.described_type_names.add(referenced_type.name)
# append the referenced types (breadth-first), unless already described in this call or earlier in the session
pending = [cls.__name__ for cls in referenced_type.get_referenced_classes()]
while pending:
type_name = pending.pop(0)
if type_name in described_in_call:
continue
contained_type = self._find_type(type_name)
if contained_type is None:
continue
described_in_call.add(type_name)
if session is not None and type_name in session.described_type_names:
parts.append(f'type {type_name}: documented earlier in this session (request `s.info("{type_name}")` to see it again)\n')
else:
parts.append(contained_type.describe())
if session is not None:
session.described_type_names.add(type_name)
pending.extend(cls.__name__ for cls in contained_type.get_referenced_classes())
return "\n".join(parts)
class SerenaRepl:
"""
Executes Python code submitted by an LLM, binding the configured facades to the entrypoint object `s`
and rendering the result of the execution as a string for the LLM.
The code is executed like the cell of a notebook: it is executed at module level in the session's namespace,
such that the names it binds (variables, functions, classes, imports) persist across executions, and if its last
statement is an expression, the expression's value is the result of the execution.
The entrypoint `s` is (re)bound in the namespace before every execution, such that persisted functions always
access the current entrypoint.
"""
SOURCE_NAME = "<serena_repl>"
ENTRYPOINT_NAME = "s"
_PERSISTED_NAME_PATTERN = re.compile(r"^(?!__)[A-Za-z_]\w*$")
@classmethod
def is_persisted_name(cls, name: str) -> bool:
"""
:param name: a name in a session namespace
:return: whether the name denotes a persisted item of the LLM's (as opposed to an implementation detail
such as `__builtins__`)
"""
return cls._PERSISTED_NAME_PATTERN.match(name) is not None
def __init__(self, facades: list[Facade], api_scope: ApiScope) -> None:
"""
:param facades: the candidate facades
:param api_scope: the API scope, which determines which of the facades are made available
"""
self._entrypoint = SerenaReplEntrypoint(facades, api_scope)
@property
def entrypoint(self) -> SerenaReplEntrypoint:
return self._entrypoint
@classmethod
def _represent(cls, obj: Any) -> str:
"""
Renders an arbitrary object as a string for the LLM. Representables render themselves,
lists and tuples are rendered element-wise (one element per line), everything else via `str`.
:param obj: the object to render
:return: the textual representation
"""
if isinstance(obj, Representable):
return obj.represent()
if isinstance(obj, list | tuple):
if len(obj) == 0:
return "[]"
return "\n".join(cls._represent(item) for item in obj)
return str(obj)
def execute(self, code: str, session: SerenaSession | None = None) -> str:
"""
Executes the given code and renders its result.
Executions are expected to be serialised (the entrypoint holds the current session during execution).
:param code: the Python code to execute
:param session: the client session on whose behalf the code is executed (None for session-less execution,
e.g. in tests), which determines e.g. which type documentation has already been provided
:return: the representation of the code's result, or a description of the error if execution failed
"""
namespace = session.repl_namespace if session is not None else {}
self._entrypoint.set_current_session_(session, namespace)
try:
result = self._run(code, namespace)
except Exception as e:
return self._format_error(e, code)
finally:
self._entrypoint.set_current_session_(None, None)
return self._represent(result)
def _run(self, code: str, namespace: dict[str, Any]) -> Any:
"""
Runs the given code at module level in the given namespace (as globals) with the entrypoint bound.
:return: the value of the code's last statement if it is an expression, None otherwise
"""
namespace[self.ENTRYPOINT_NAME] = self._entrypoint
module = ast.parse(code, self.SOURCE_NAME)
# separate a trailing expression, whose value is the result
statements = module.body
trailing_expression: ast.expr | None = None
if statements and isinstance(statements[-1], ast.Expr):
trailing_expression = statements[-1].value
statements = statements[:-1]
# execute the statements, then evaluate the trailing expression (both retain the original line numbers)
if statements:
exec(compile(ast.Module(body=statements, type_ignores=[]), self.SOURCE_NAME, "exec"), namespace)
if trailing_expression is not None:
return eval(compile(ast.Expression(body=trailing_expression), self.SOURCE_NAME, "eval"), namespace)
return None
def _format_error(self, e: Exception, code: str) -> str:
"""
:param e: the exception raised during execution
:param code: the code that was executed
:return: an error message which locates the failure within the executed code
"""
code_lines = code.splitlines()
def location_line(line_number: int) -> str:
line_text = code_lines[line_number - 1].strip() if 0 < line_number <= len(code_lines) else ""
return f" line {line_number}: {line_text}"
# report syntax errors in the executed code (which carry no traceback frames of their own)
if isinstance(e, SyntaxError) and e.filename == self.SOURCE_NAME and e.lineno is not None:
message = f"SyntaxError: {e.msg}\n" + location_line(e.lineno)
if "return" in (e.msg or "") and "outside function" in e.msg:
message += "\nNote: the code is executed like a notebook cell; the value of the last expression is the result (do not use `return`)."
return message
# report runtime errors, locating them within the executed code
location_lines = []
for frame in traceback.extract_tb(e.__traceback__):
if frame.filename != self.SOURCE_NAME or frame.lineno is None:
continue
location_lines.append(location_line(frame.lineno))
return "\n".join([f"{type(e).__name__}: {e}", *location_lines])
+106
View File
@@ -0,0 +1,106 @@
"""
The representation protocol through which objects returned from REPL code are rendered for the LLM.
"""
# SPDX-License-Identifier: GPL-3.0-or-later
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from serena.util.text_utils import TextOutputUtils
if TYPE_CHECKING:
from serena.agent import SerenaAgent
T = TypeVar("T")
class Renderer(Generic[T], ABC):
def __init__(self, agent: "SerenaAgent", max_answer_chars: int = -1):
"""
:param agent: the agent, from which the configured default length limit is taken (the agent itself is not
retained, such that results remain picklable)
:param max_answer_chars: the maximum number of characters; -1 for the configured default
"""
self._default_max_answer_chars = agent.serena_config.default_max_tool_answer_chars
self._max_answer_chars = max_answer_chars
def _limit_length(
self,
result: str,
shortened_result_factories: list[Callable[[], str]] | None = None,
) -> str:
"""Limit the length of the result string, optionally trying progressively shorter versions.
:param result: the full result string
:param max_answer_chars: maximum allowed characters. -1 means use the default from config.
:param shortened_result_factories: optional list of closures, each producing a progressively shorter
version of the result. They are tried in order until one fits within ``max_answer_chars``.
:return: the result string, potentially replaced by a shortened version
"""
return TextOutputUtils.limit_length(
result=result, max_answer_chars=self._get_max_answer_chars(), shortened_result_factories=shortened_result_factories
)
def _get_max_answer_chars(self) -> int:
"""
:return: the effective maximum number of characters, resolving the default from the configuration
"""
return self._default_max_answer_chars if self._max_answer_chars == -1 else self._max_answer_chars
def _to_json(self, x: Any) -> str:
return TextOutputUtils.to_json(x)
@abstractmethod
def render(self, obj: T) -> str:
"""
:return: a textual representation of this object for the LLM
"""
class Representable(ABC):
"""
An object which can render itself as a string suitable for consumption by an LLM.
"""
@abstractmethod
def represent(self) -> str:
"""
:return: a textual representation of this object for the LLM
"""
class RepresentableViaRenderer(Representable):
"""
A representable object which uses a renderer to render itself.
"""
def __init__(self, renderer: Renderer):
self._renderer = renderer
def represent(self) -> str:
return self._renderer.render(self)
class JsonObject(RepresentableViaRenderer):
"""
A JSON-serializable result (dict, list, etc.) which is rendered as JSON, subject to length limitation.
"""
def __init__(self, data: Any, renderer: "JsonObjectRenderer"):
"""
:param data: the JSON-serializable data
:param renderer: the renderer to use for representing the data
"""
super().__init__(renderer)
self.data = data
data: Any
"""the JSON-serializable data (dict, list, etc.)"""
class JsonObjectRenderer(Renderer[JsonObject]):
def render(self, obj: JsonObject) -> str:
return self._limit_length(self._to_json(obj.data))
@@ -23,6 +23,13 @@ included_optional_tools: []
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html # Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: [] fixed_tools: []
# APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to be excluded from the REPL in this context.
excluded_apis: []
# included APIs that would otherwise be excluded (particularly optional facade methods, which are disabled by default),
# e.g. "lsp.get_diagnostics_for_symbol".
included_apis: []
# mapping of tool names to an override of their descriptions (the default description is the docstring of the Tool's apply method). # mapping of tool names to an override of their descriptions (the default description is the docstring of the Tool's apply method).
# Sometimes, tool descriptions are too long (e.g., for ChatGPT), or users may want to override them for another reason. # Sometimes, tool descriptions are too long (e.g., for ChatGPT), or users may want to override them for another reason.
tool_description_overrides: {} tool_description_overrides: {}
@@ -18,3 +18,5 @@ excluded_tools:
- delete_memory - delete_memory
- rename_memory - rename_memory
- onboarding - onboarding
excluded_apis:
- mem
@@ -4,7 +4,7 @@ prompt: |
**Refactoring tools** **Refactoring tools**
For operations on existing symbols, prefer the dedicated refactoring tools over hand-edits: For operations on existing symbols, prefer the dedicated refactoring tools over hand-edits:
`{{ tool_names['rename_symbol'] }}` and `{{ tool_names['safe_delete_symbol'] }}`{% if 'jet_brains_move' in available_tools %}, plus `jet_brains_move` and `jet_brains_inline_symbol`{% endif %} — `{{ tool_names['rename_symbol'] }}` and `{{ tool_names['safe_delete_symbol'] }}`{% if 'jet_brains_move' in available_tools %}, plus `{{ tool_names['jet_brains_move'] }}` and `{{ tool_names['jet_brains_inline_symbol'] }}`{% endif %} —
they are reference-aware and update or check all usages atomically. When such a tool returns success, the they are reference-aware and update or check all usages atomically. When such a tool returns success, the
refactoring is already complete and consistent across all declarations, references, overrides and imports — refactoring is already complete and consistent across all declarations, references, overrides and imports —
trust it: do not re-read the changed files or re-run the build / test suite just to confirm the refactor trust it: do not re-read the changed files or re-run the build / test suite just to confirm the refactor
@@ -16,9 +16,9 @@ prompt: |
**Symbolic editing** **Symbolic editing**
Use symbolic retrieval tools to identify the symbols you need to edit. Use symbolic retrieval tools to identify the symbols you need to edit.
If you need to replace the definition of a symbol, use the `replace_symbol_body` tool. If you need to replace the definition of a symbol, use the `{{ tool_names['replace_symbol_body'] }}` tool.
If you want to add some new code at the end of the file, use the `insert_after_symbol` tool with the last top-level symbol in the file. If you want to add some new code at the end of the file, use the `{{ tool_names['insert_after_symbol'] }}` tool with the last top-level symbol in the file.
Similarly, you can use `insert_before_symbol` with the first top-level symbol in the file to insert code at the beginning of a file. Similarly, you can use `{{ tool_names['insert_before_symbol'] }}` with the first top-level symbol in the file to insert code at the beginning of a file.
You can understand relationships between symbols by using the `{{ tool_names['find_referencing_symbols'] }}` tool. If not explicitly requested otherwise by the user, You can understand relationships between symbols by using the `{{ tool_names['find_referencing_symbols'] }}` tool. If not explicitly requested otherwise by the user,
you make sure that when you edit a symbol, the change is either backward-compatible or you find and update all references as needed. you make sure that when you edit a symbol, the change is either backward-compatible or you find and update all references as needed.
The `{{ tool_names['find_referencing_symbols'] }}` tool will give you code snippets around the references as well as symbolic information. The `{{ tool_names['find_referencing_symbols'] }}` tool will give you code snippets around the references as well as symbolic information.
@@ -26,14 +26,14 @@ prompt: |
{% if 'replace_content' in available_tools %} {% if 'replace_content' in available_tools %}
**File-based editing** **File-based editing**
The `replace_content` tool allows you to perform regex-based replacements within files (as well as simple string replacements). The `{{ tool_names['replace_content'] }}` tool allows you to perform regex-based replacements within files (as well as simple string replacements).
This is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation, This is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation,
e.g. if you need to adjust just a few lines of code within a method. e.g. if you need to adjust just a few lines of code within a method.
In `regex` mode, wildcards like `start.*?end` let you match a span without quoting its full text; an ambiguous match In `regex` mode, wildcards like `start.*?end` let you match a span without quoting its full text; an ambiguous match
returns an error you can refine, so a tight wildcard pattern is both cheaper and safe. returns an error you can refine, so a tight wildcard pattern is both cheaper and safe.
For several small edits within one file, prefer a batch of targeted `replace_content` calls over rewriting the whole For several small edits within one file, prefer a batch of targeted `{{ tool_names['replace_content'] }}` calls over rewriting the whole
file: rewriting has to re-emit the file's entire contents, whereas each targeted edit emits only the changed text. file: rewriting has to re-emit the file's entire contents, whereas each targeted edit emits only the changed text.
{% if 'replace_in_files' in available_tools %}For the SAME edit across many files, use `replace_in_files`, which applies {% if 'replace_in_files' in available_tools %}For the SAME edit across many files, use `{{ tool_names['replace_in_files'] }}`, which applies
it everywhere in one call — equally safe and transparent: its `dry_run` mode first previews every change as a diff with a it everywhere in one call — equally safe and transparent: its `dry_run` mode first previews every change as a diff with a
per-occurrence id, so you can then apply all of them or just a chosen subset.{% endif %} per-occurrence id, so you can then apply all of them or just a chosen subset.{% endif %}
{% endif %} {% endif %}
@@ -22,4 +22,11 @@ included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html # Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: [] fixed_tools: []
# APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to be excluded from the REPL in this mode.
excluded_apis: []
# included APIs that would otherwise be excluded (particularly optional facade methods, which are disabled by default),
# e.g. "lsp.get_diagnostics_for_symbol".
included_apis: []
@@ -9,3 +9,5 @@ excluded_tools:
- rename_memory - rename_memory
- list_memories - list_memories
- onboarding - onboarding
excluded_apis:
- mem
@@ -3,3 +3,5 @@ prompt: |
The onboarding process is not applied. The onboarding process is not applied.
excluded_tools: excluded_tools:
- onboarding - onboarding
excluded_apis:
- mem.onboarding
@@ -6,3 +6,5 @@ excluded_tools: []
included_optional_tools: included_optional_tools:
- list_queryable_projects - list_queryable_projects
- query_project - query_project
included_apis:
- ext
@@ -5,29 +5,28 @@ prompts:
connection_prompt: | connection_prompt: |
CRITICAL: Before starting to work on a coding task, call the `initial_instructions` tool to read the 'Serena Instructions Manual'. CRITICAL: Before starting to work on a coding task, call the `initial_instructions` tool to read the 'Serena Instructions Manual'.
system_prompt: | system_prompt: |
You have semantic coding tools that you rely on heavily. Work resource-efficiently: don't read or You have semantic coding functions/tools that you rely on heavily. Work resource-efficiently: don't read or
generate content the task doesn't need. generate content the task doesn't need.
Some tasks require understanding a large part of the codebase; others need only a few symbols or a Some tasks require understanding a large part of the codebase; others need only a few symbols or a
single file. Avoid reading whole files unless necessary — acquire information step by step, using single file. Avoid reading whole files unless necessary — acquire information step by step, using
the symbolic tools to get an overview of symbols and their relations, then reading only the bodies the symbolic functions to get an overview of symbols and their relations, then reading only the bodies
you need. {% if 'ToolMarkerSymbolicRead' in available_markers %}Once you have read a full file, there is no point re-analysing it with the symbolic read you need. {% if 'ToolMarkerSymbolicRead' in available_markers %}Once you have read a full file, there is no point re-analysing it with the symbolic read
tools — you already have it.{% endif %} tools — you already have it.{% endif %}
{% if 'search_for_pattern' in available_tools %} {% if 'search_for_pattern' in available_tools %}
If you are unsure about a symbol's name or location{% if 'find_symbol' in available_tools %} (to the extent that substring_matching for the symbol name is not enough){% endif %}, you can use the `search_for_pattern` tool, which allows fast For searches beyond named symbols, you can use `{{ tool_names['search_for_pattern'] }}` and flexible search for patterns in the codebase.{% if 'ToolMarkerSymbolicRead' in available_markers %} In this way, you can first find candidates for symbols or files to explore,
and flexible search for patterns in the codebase.{% if 'ToolMarkerSymbolicRead' in available_markers %} In this way, you can first find candidates for symbols or files, and then proceed with symbolic operations.{% endif %}
and then proceed with the symbolic tools.{% endif %}
{% endif %} {% endif %}
{% if 'ToolMarkerSymbolicRead' in available_markers %} {% if 'ToolMarkerSymbolicRead' in available_markers %}
Symbols are identified by their `name_path` and `relative_path`. Symbols are identified by their `name_path` and `relative_path`.
You can get an overview of the symbols in a file by using the `{{ tool_names['get_symbols_overview'] }}` tool, or search for a specific symbol with `{{ tool_names['find_symbol'] }}`. You can get an overview of the symbols in a file by using `{{ tool_names['get_symbols_overview'] }}`, or search for a specific symbol with `{{ tool_names['find_symbol'] }}`.
You only read the bodies of symbols when you need to (e.g. if you want to fully understand or edit it). You only read the bodies of symbols when you need to (e.g. if you want to fully understand or edit it).
For example, if you are working with Python code and already know that you need to read the body of the constructor of the class Foo, you can directly For example, if you are working with Python code and already know that you need to read the body of the constructor of the class Foo, you can directly
use `{{ tool_names['find_symbol'] }}` with name path pattern `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit, use `{{ tool_names['find_symbol'] }}` with name path pattern `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit,
you can use `{{ tool_names['find_symbol'] }}` with name path pattern `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding you can use `{{ tool_names['find_symbol'] }}` with name path pattern `Foo`, `include_body=False` and `depth=1` to get all (top-level) members of `Foo` before proceeding
to read the desired methods with `include_body=True`. to read the desired methods with `include_body=True`.
You can understand relationships between symbols by using the `{{ tool_names['find_referencing_symbols'] }}` tool. You can understand relationships between symbols by using `{{ tool_names['find_referencing_symbols'] }}`.
{% endif %} {% endif %}
{% if 'read_memory' in available_tools -%} {% if 'read_memory' in available_tools -%}
@@ -603,6 +603,23 @@ code, pre, kbd, samp,
cursor: default; cursor: default;
} }
.facade-block {
margin-bottom: 12px;
}
.facade-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.facade-name.disabled,
.tool-item.disabled {
color: var(--text-secondary);
opacity: 0.5;
}
/* Projects List */ /* Projects List */
.project-item { .project-item {
padding: 10px 12px; padding: 10px 12px;
+44 -4
View File
@@ -614,6 +614,7 @@ class Dashboard {
const $existingToolsContent = $('#tools-content'); const $existingToolsContent = $('#tools-content');
const $existingMemoriesContent = $('#memories-content'); const $existingMemoriesContent = $('#memories-content');
const wasToolsExpanded = $existingToolsContent.is(':visible'); const wasToolsExpanded = $existingToolsContent.is(':visible');
const wasFunctionsExpanded = $('#functions-content').is(':visible');
const wasMemoriesExpanded = $existingMemoriesContent.is(':visible'); const wasMemoriesExpanded = $existingMemoriesContent.is(':visible');
let html = '<div class="config-grid">'; let html = '<div class="config-grid">';
@@ -634,10 +635,14 @@ class Dashboard {
html += '<div class="config-value">' + (config.active_project.name || 'None') + '</div>'; html += '<div class="config-value">' + (config.active_project.name || 'None') + '</div>';
} }
html += '<div class="config-label">Languages:</div>'; html += '<div class="config-label">Interface:</div>';
if (this.jetbrainsMode) { html += '<div class="config-value">' + config.agent_interface + '</div>';
html += '<div class="config-value">Using JetBrains backend</div>';
} else { html += '<div class="config-label">Backend:</div>';
html += '<div class="config-value">' + config.language_backend + '</div>';
if (!this.jetbrainsMode) {
html += '<div class="config-label">Languages:</div>';
html += '<div class="config-value">'; html += '<div class="config-value">';
if (config.languages && config.languages.length > 0) { if (config.languages && config.languages.length > 0) {
html += '<div class="languages-container">'; html += '<div class="languages-container">';
@@ -705,6 +710,32 @@ class Dashboard {
html += '</div>'; html += '</div>';
html += '</div>'; html += '</div>';
// Active functions of the REPL's facades - collapsible (REPL interface only)
if (config.facades) {
const enabledMethodCount = config.facades.reduce(function (count, facade) {
return count + facade.methods.filter(function (method) { return method.is_enabled; }).length;
}, 0);
html += '<div style="margin-top: 20px;">';
html += '<h3 class="collapsible-header" id="functions-header" style="font-size: 16px; margin: 0;">';
html += '<span>Active Functions (' + enabledMethodCount + ')</span>';
html += '<span class="toggle-icon' + (wasFunctionsExpanded ? ' expanded' : '') + '">▼</span>';
html += '</h3>';
html += '<div class="collapsible-content" id="functions-content" style="' + (wasFunctionsExpanded ? '' : 'display:none;') + ' margin-top: 10px;">';
config.facades.forEach(function (facade) {
html += '<div class="facade-block">';
html += '<div class="facade-name' + (facade.is_enabled ? '' : ' disabled') + '">s.' + facade.name + '</div>';
html += '<div class="tools-grid">';
facade.methods.forEach(function (method) {
const title = facade.name + '.' + method.name + (method.is_enabled ? '' : ' (disabled)');
html += '<div class="tool-item' + (method.is_enabled ? '' : ' disabled') + '" title="' + title + '">' + method.name + '</div>';
});
html += '</div>';
html += '</div>';
});
html += '</div>';
html += '</div>';
}
// Available memories - collapsible (show if memories exist or if project exists) // Available memories - collapsible (show if memories exist or if project exists)
if (config.active_project && config.active_project.name) { if (config.active_project && config.active_project.name) {
html += '<div style="margin-top: 20px;">'; html += '<div style="margin-top: 20px;">';
@@ -773,6 +804,15 @@ class Dashboard {
$('#create-memory-btn').click(this.openCreateMemoryModal.bind(this)); $('#create-memory-btn').click(this.openCreateMemoryModal.bind(this));
// Re-attach collapsible handler for the newly created tools header // Re-attach collapsible handler for the newly created tools header
$('#functions-header').click(function () {
const $header = $(this);
const $content = $('#functions-content');
const $icon = $header.find('.toggle-icon');
$content.slideToggle(300);
$icon.toggleClass('expanded');
});
$('#tools-header').click(function () { $('#tools-header').click(function () {
const $header = $(this); const $header = $(this);
const $content = $('#tools-content'); const $content = $('#tools-content');
+14
View File
@@ -68,6 +68,11 @@ line_ending:
# is activated post-init, an error will be returned. # is activated post-init, an error will be returned.
language_backend: language_backend:
# The interface through which the agent (LLM) accesses Serena's functionality (overrides the global setting).
# Valid values: tools, REPL (see the global configuration for details); leave empty to use the global setting.
# Note: the interface is fixed at startup. If a project is activated post-init, its setting is not applied.
agent_interface:
# whether to use project's .gitignore files to ignore files # whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true ignore_all_files_in_gitignore: true
@@ -131,6 +136,15 @@ included_optional_tools: []
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html # Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: [] fixed_tools: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to exclude from the REPL.
# This extends the existing exclusions (e.g. from the global configuration).
excluded_apis: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.get_diagnostics_for_symbol") to include in the REPL
# that would otherwise be disabled (particularly optional methods, which are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_apis: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration. # list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. # The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
@@ -7,6 +7,18 @@
# in your IDE). # in your IDE).
language_backend: LSP language_backend: LSP
# The interface through which the agent (LLM) accesses Serena's functionality:
# * tools: the classic tool interface, in which each operation is a separate tool.
# The set of tools is configurable via the tool inclusion/exclusion settings (excluded_tools etc.)
# of the configuration, the context, modes and the project.
# * REPL: operations are accessed programmatically via the serena_repl tool, which executes Python code.
# The set of tools is fixed (the REPL tool and the tools required for session management), and the tool
# inclusion/exclusion settings do not apply; instead, the operations available in the REPL are configured
# via the API inclusion/exclusion settings (excluded_apis etc.).
# The interface is fixed at startup; it can be overridden by the project activated at startup.
# IMPORTANT: The REPL interface is a BETA feature. Please provide feedback; if you encounter issues, report them.
agent_interface: tools
# line ending convention to use when writing source files. # line ending convention to use when writing source files.
# Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default). # Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default).
# Note that Serena's own files (e.g. memories and configuration files) always use native line endings. # Note that Serena's own files (e.g. memories and configuration files) always use native line endings.
@@ -151,6 +163,13 @@ included_optional_tools: []
# This cannot be combined with non-empty excluded_tools or included_optional_tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: [] fixed_tools: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to be globally excluded from the REPL
excluded_apis: []
# list of APIs (facades or facade methods, e.g. "lsp.get_diagnostics_for_symbol") to be included in the REPL
# (particularly optional methods, which are disabled by default)
included_apis: []
# list of mode names to that are always to be included in the set of active modes. # list of mode names to that are always to be included in the set of active modes.
# The full set of modes to be activated is base_modes + default_modes + added_modes, # The full set of modes to be activated is base_modes + default_modes + added_modes,
# where added_modes can be defined by projects/CLI parameters. # where added_modes can be defined by projects/CLI parameters.
@@ -221,5 +240,9 @@ project_serena_folder_location: "$projectDir/.serena"
# The pattern "**" matches any project path, so it can be used to trust all projects. # The pattern "**" matches any project path, so it can be used to trust all projects.
trusted_project_path_patterns: [] trusted_project_path_patterns: []
# shared secret for authenticating communication between Serena components and services.
# Keep this value private. When missing, null, or empty, a random UUID is generated and saved on load.
auth_secret:
# the list of registered project paths (updated automatically). # the list of registered project paths (updated automatically).
projects: [] projects: []
+89
View File
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Client sessions (conversations) and their state.
"""
import logging
import secrets
import time
from collections import OrderedDict
from typing import Any
log = logging.getLogger(__name__)
class SerenaSession:
"""
A client session, i.e. a conversation between an LLM and Serena, and the state pertaining to it.
Session identity is supplied by the LLM: the session id is issued as part of Serena's instructions
(system prompt/initial instructions) and passed by the LLM to tools which require it.
"""
def __init__(self, session_id: str) -> None:
self.session_id = session_id
self.described_type_names: set[str] = set()
"""the names of the REPL's result types whose documentation has already been provided in this session"""
self.repl_namespace: dict[str, Any] = {}
"""
the namespace (globals) of the session's REPL code executions: variables and functions defined at the top level
of executed code persist here across executions for the lifetime of the session
"""
self.last_access_time = time.time()
class SessionRegistry:
"""
Holds the sessions of an agent, creating them on demand and evicting sessions which have been idle for too long
as well as the least recently used ones if their number exceeds the limit (session ids being supplied by LLMs,
the set of ids is not controlled).
"""
def __init__(self, max_sessions: int = 100, idle_ttl_seconds: float = 6 * 3600) -> None:
"""
:param max_sessions: the maximum number of sessions to keep
:param idle_ttl_seconds: the time after which an idle session is evicted (releasing its REPL namespace)
"""
self._max_sessions = max_sessions
self._idle_ttl_seconds = idle_ttl_seconds
self._sessions: OrderedDict[str, SerenaSession] = OrderedDict()
@staticmethod
def _next_session_id() -> str:
return secrets.token_hex(4)
def create_session(self) -> SerenaSession:
"""
:return: a new session with a random id
"""
return self.get_session(self._next_session_id())
def get_session(self, session_id: str) -> SerenaSession:
"""
:param session_id: the session id
:return: the session, which is created if it is unknown (an unknown id may e.g. stem from a session that has been
evicted or from an earlier run of the server)
"""
self._evict_idle_sessions()
session = self._sessions.get(session_id)
if session is None:
session = SerenaSession(session_id)
self._sessions[session_id] = session
log.info("Created session %s (%d sessions)", session_id, len(self._sessions))
while len(self._sessions) > self._max_sessions:
evicted_id, _ = self._sessions.popitem(last=False)
log.info("Evicted session %s (session limit)", evicted_id)
else:
self._sessions.move_to_end(session_id)
session.last_access_time = time.time()
return session
def _evict_idle_sessions(self) -> None:
# sessions are ordered by last access, so the idle ones are at the front
now = time.time()
while self._sessions:
oldest_id, oldest = next(iter(self._sessions.items()))
if now - oldest.last_access_time <= self._idle_ttl_seconds:
break
del self._sessions[oldest_id]
log.info("Evicted session %s (idle)", oldest_id)
+1
View File
@@ -10,3 +10,4 @@ from .config_tools import *
from .workflow_tools import * from .workflow_tools import *
from .jetbrains_tools import * from .jetbrains_tools import *
from .query_project_tools import * from .query_project_tools import *
from .repl_tools import *
+19 -18
View File
@@ -3,13 +3,28 @@ Tools supporting the execution of (external) commands
""" """
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os.path from typing import TYPE_CHECKING, cast
from serena.tools import Tool, ToolMarkerCanEdit from serena.tools import Tool, ToolMarkerCanEdit
from serena.util.shell import execute_shell_command
if TYPE_CHECKING:
from serena.repl.api.shell_api import ShellApi
class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): class ShellApiMixin:
"""
Mixin for tools which delegate to the shell API.
The API is imported locally, since the API module refers to the tools (as corresponding tools).
"""
def _api(self) -> "ShellApi":
from serena.repl.api.shell_api import ShellApi
tool = cast(Tool, cast(object, self))
return ShellApi(tool.agent)
class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit, ShellApiMixin):
""" """
Executes a shell command. Executes a shell command.
""" """
@@ -36,18 +51,4 @@ class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit):
required for the task. required for the task.
:return: a JSON object containing the command's stdout and optionally stderr output :return: a JSON object containing the command's stdout and optionally stderr output
""" """
if cwd is None: return self._api().execute_shell_command(command, cwd, capture_stderr, max_answer_chars).represent()
_cwd = self.get_project_root()
else:
if os.path.isabs(cwd):
_cwd = cwd
else:
_cwd = os.path.join(self.get_project_root(), cwd)
if not os.path.isdir(_cwd):
raise FileNotFoundError(
f"Specified a relative working directory ({cwd}), but the resulting path is not a directory: {_cwd}"
)
result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr)
result = result.model_dump_json()
return self._limit_length(result, max_answer_chars)
+23 -9
View File
@@ -1,11 +1,29 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from typing import TYPE_CHECKING, cast
from sensai.util.helper import mark_used from sensai.util.helper import mark_used
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
if TYPE_CHECKING:
from serena.repl.api.cfg_api import ConfigApi
class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject):
class ConfigApiMixin:
"""
Mixin for tools which delegate to the configuration API.
The API is imported locally, since the API module refers to the tools (as corresponding tools).
"""
def _api(self) -> "ConfigApi":
from serena.repl.api.cfg_api import ConfigApi
tool = cast(Tool, cast(object, self))
return ConfigApi(tool.agent)
class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject, ConfigApiMixin):
""" """
Opens the Serena web dashboard in the default web browser. Opens the Serena web dashboard in the default web browser.
The dashboard provides logs, session information, and tool usage statistics. The dashboard provides logs, session information, and tool usage statistics.
@@ -15,10 +33,7 @@ class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActive
""" """
Opens the Serena web dashboard in the default web browser. Opens the Serena web dashboard in the default web browser.
""" """
if self.agent.open_dashboard(): return self._api().open_dashboard()
return f"Serena web dashboard has been opened in the user's default web browser: {self.agent.get_dashboard_url()}"
else:
return f"Serena web dashboard could not be opened automatically; tell the user to open it via {self.agent.get_dashboard_url()}"
class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject):
@@ -26,13 +41,12 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject):
Activates a project based on the project name or path. Activates a project based on the project name or path.
""" """
# noinspection PyIncorrectDocstring
# (session_id is injected via apply_ex)
def apply(self, project: str, session_id: str) -> str: def apply(self, project: str, session_id: str) -> str:
""" """
Activates the project with the given name or path. Activates the project with the given name or path.
:param project: the name of a registered project to activate or a path to a project directory :param project: the name of a registered project to activate or a path to a project directory
:param session_id: your Serena session id, as provided in Serena's instructions (call `initial_instructions` if you do not have one)
""" """
is_new_activation = self.agent.activate_project_from_path_or_name(project) is_new_activation = self.agent.activate_project_from_path_or_name(project)
mark_used(is_new_activation) mark_used(is_new_activation)
@@ -56,7 +70,7 @@ class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerO
return f"Successfully removed project '{project_name}' from configuration." return f"Successfully removed project '{project_name}' from configuration."
class GetCurrentConfigTool(Tool): class GetCurrentConfigTool(Tool, ConfigApiMixin):
""" """
Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
""" """
@@ -65,4 +79,4 @@ class GetCurrentConfigTool(Tool):
""" """
Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes. Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
""" """
return self.agent.get_current_config_overview() return self._api().get_current_config()
+92 -394
View File
@@ -7,24 +7,42 @@ File and file system-related tools, specifically for
""" """
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os from typing import TYPE_CHECKING, Literal, cast
from collections import defaultdict
from fnmatch import fnmatch
from pathlib import Path
from typing import Literal
from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional from serena.tools import EditingToolWithDiagnostics, Tool, ToolMarkerOptional
from serena.util.file_system import scan_directory
from serena.util.text_utils import ( if TYPE_CHECKING:
ContentReplacer, from serena.repl.api.edit_api import EditApi
GlobMatcher, from serena.repl.api.fs_api import FsApi
MultiFileContentReplacer,
ReplacementOccurrence,
)
from solidlsp.ls_utils import TextUtils
class ReadFileTool(Tool): class EditApiMixin:
"""
Mixin for tools which delegate to the editing API.
The API is imported locally, since the API module refers to the tools (as corresponding tools).
"""
def _api(self) -> "EditApi":
from serena.repl.api.edit_api import EditApi
tool = cast(Tool, cast(object, self))
return EditApi(tool.agent)
class FsApiMixin:
"""
Mixin for tools which delegate to the file system API.
The API is imported locally, since the API module refers to the tools (as corresponding tools).
"""
def _api(self) -> "FsApi":
from serena.repl.api.fs_api import FsApi
tool = cast(Tool, cast(object, self))
return FsApi(tool.agent)
class ReadFileTool(Tool, FsApiMixin):
""" """
Reads a file within the project directory. Reads a file within the project directory.
""" """
@@ -41,22 +59,10 @@ class ReadFileTool(Tool):
required for the task. required for the task.
:return: the full text of the file at the given relative path :return: the full text of the file at the given relative path
""" """
self.project.validate_relative_path(relative_path) return self._api().read_file(relative_path, start_line, end_line, max_answer_chars).represent()
# read lines, using the same (LSP-compliant) notion of line breaks as the line-based editing tools
result = self.project.read_file(relative_path)
result_lines = TextUtils.split_lines(result)
if end_line is None:
result_lines = result_lines[start_line:]
else:
result_lines = result_lines[start_line : end_line + 1]
result = "\n".join(result_lines)
return self._limit_length(result, max_answer_chars)
class CreateTextFileTool(EditingToolWithDiagnostics): class CreateTextFileTool(EditingToolWithDiagnostics, FsApiMixin):
""" """
Creates/overwrites a file in the project directory. Creates/overwrites a file in the project directory.
""" """
@@ -69,30 +75,11 @@ class CreateTextFileTool(EditingToolWithDiagnostics):
:param content: the (appropriately encoded) content to write to the file :param content: the (appropriately encoded) content to write to the file
:return: a message indicating success or failure :return: a message indicating success or failure
""" """
with self.DiagnosticsContext(self, relative_path) as diagnostics_context: with self.diagnostics_context(relative_path) as diagnostics_context:
# validating the destination path return diagnostics_context.format_result(self._api().create_text_file(relative_path, content))
project_root = self.get_project_root()
abs_path = (Path(project_root) / relative_path).resolve()
will_overwrite_existing = abs_path.exists()
if will_overwrite_existing:
self.project.validate_relative_path(relative_path)
else:
assert abs_path.is_relative_to(self.get_project_root()), (
f"Cannot create file outside of the project directory, got {relative_path=}"
)
# writing the file
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str)
answer = f"File created: {relative_path}."
if will_overwrite_existing:
answer += " Overwrote existing file."
return diagnostics_context.format_result(answer)
class ListDirTool(Tool): class ListDirTool(Tool, FsApiMixin):
""" """
Lists files and directories in the given directory (optionally with recursion). Lists files and directories in the given directory (optionally with recursion).
""" """
@@ -109,31 +96,13 @@ class ListDirTool(Tool):
Don't adjust unless there is really no other way to get the content required for the task. Don't adjust unless there is really no other way to get the content required for the task.
:return: a JSON object with the names of directories and files within the given directory :return: a JSON object with the names of directories and files within the given directory
""" """
# Check if the directory exists before validation try:
if not self.project.relative_path_exists(relative_path): return self._api().list_dir(relative_path, recursive, skip_ignored_files, max_answer_chars).represent()
error_info = { except FileNotFoundError as e:
"error": f"Directory not found: {relative_path}", return self._to_json({"error": str(e), "project_root": self.get_project_root()})
"project_root": self.get_project_root(),
"hint": "Check if the path is correct relative to the project root",
}
return self._to_json(error_info)
self.project.validate_relative_path(relative_path)
is_ignored_path_fn = self.project.get_is_ignored_path_fn(relative_path, skip_ignored_files)
dirs, files = scan_directory(
os.path.join(self.get_project_root(), relative_path),
relative_to=self.get_project_root(),
recursive=recursive,
is_ignored_dir=is_ignored_path_fn,
is_ignored_file=is_ignored_path_fn,
)
result = self._to_json({"dirs": dirs, "files": files})
return self._limit_length(result, max_answer_chars)
class FindFileTool(Tool): class FindFileTool(Tool, FsApiMixin):
""" """
Finds files in the given relative paths Finds files in the given relative paths
""" """
@@ -147,31 +116,10 @@ class FindFileTool(Tool):
:param skip_ignored_files: whether to skip ignored files/directories :param skip_ignored_files: whether to skip ignored files/directories
:return: a JSON object with the list of matching files :return: a JSON object with the list of matching files
""" """
self.project.validate_relative_path(relative_path) return self._to_json({"files": self._api().find_file(file_mask, relative_path)})
is_ignored_path_fn = self.project.get_is_ignored_path_fn(relative_path, skip_ignored_paths=False)
dir_to_scan = os.path.join(self.get_project_root(), relative_path)
# find the files by ignoring everything that doesn't match
def is_ignored_file(abs_path: str) -> bool:
if is_ignored_path_fn(abs_path):
return True
filename = os.path.basename(abs_path)
return not fnmatch(filename, file_mask)
_dirs, files = scan_directory(
path=dir_to_scan,
recursive=True,
is_ignored_dir=is_ignored_path_fn,
is_ignored_file=is_ignored_file,
relative_to=self.get_project_root(),
)
result = self._to_json({"files": files})
return result
class ReplaceContentTool(EditingToolWithDiagnostics): class ReplaceContentTool(EditingToolWithDiagnostics, EditApiMixin):
""" """
Replaces content in a file (optionally using regular expressions). Replaces content in a file (optionally using regular expressions).
""" """
@@ -206,17 +154,13 @@ class ReplaceContentTool(EditingToolWithDiagnostics):
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences. :param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
If false and multiple occurrences are found, an error will be returned If false and multiple occurrences are found, an error will be returned
""" """
with self.DiagnosticsContext(self, relative_path) as diagnostics_context: with self.diagnostics_context(relative_path) as diagnostics_context:
self.project.validate_relative_path(relative_path) return diagnostics_context.format_result(
with EditedFileContext(relative_path, self.create_code_editor()) as context: self._api().replace_content(relative_path, needle, repl, mode, allow_multiple_occurrences=allow_multiple_occurrences)
original_content = context.get_original_content() )
replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences)
updated_content = replacer.replace(original_content, needle, repl)
context.set_updated_content(updated_content)
return diagnostics_context.format_result(SUCCESS_RESULT)
class ReplaceInFilesTool(EditingToolWithDiagnostics): class ReplaceInFilesTool(EditingToolWithDiagnostics, EditApiMixin):
""" """
Replaces occurrences of a pattern across multiple files, with dry-run preview and per-occurrence selection. Replaces occurrences of a pattern across multiple files, with dry-run preview and per-occurrence selection.
""" """
@@ -272,184 +216,28 @@ class ReplaceInFilesTool(EditingToolWithDiagnostics):
returned. -1 uses the configured default. returned. -1 uses the configured default.
:return: in a dry run, the prospective changes; otherwise a summary of the applied replacements :return: in a dry run, the prospective changes; otherwise a summary of the applied replacements
""" """
replacer = MultiFileContentReplacer(mode=mode) api = self._api()
files = self._collect_files(relative_path, paths_include_glob, paths_exclude_glob)
occurrences = replacer.find_occurrences(files, needle, repl)
contents = dict(files)
if dry_run: if dry_run:
return self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=True) return api.replace_in_files(
needle, repl, mode, relative_path, paths_include_glob, paths_exclude_glob, dry_run=True, max_answer_chars=max_answer_chars
if occurrence_ids is not None: ).represent()
selected, problems = self._resolve_occurrence_ids(occurrence_ids, occurrences) with self.diagnostics_context() as diagnostics_context:
if problems: result = api.replace_in_files(
problem_lines = "\n".join(f" {p}" for p in problems) needle,
raise ValueError( repl,
f"{len(problems)} of the given occurrence_ids could not be resolved - NO changes were applied:\n" mode,
f"{problem_lines}\n" relative_path,
"Re-run with dry_run=True to obtain current occurrence ids." paths_include_glob,
) paths_exclude_glob,
if not selected: occurrence_ids=occurrence_ids,
raise ValueError("occurrence_ids is empty - pass at least one id from a dry run, or omit the parameter to replace all.") expected_count=expected_count,
return self._apply_occurrences(replacer, selected, contents, needle, repl) max_answer_chars=max_answer_chars,
# blind apply (no ids)
if not occurrences:
raise ValueError(
"No occurrences of the pattern were found - NO changes were applied. "
"Check the mode (a literal needle containing regex metacharacters must use mode 'literal'; "
"wildcards require mode 'regex') and the path/glob restrictions, "
"or locate the content with search_for_pattern first."
) )
if expected_count >= 0 and len(occurrences) != expected_count: assert isinstance(result, str)
listing = self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=False) return diagnostics_context.format_result(result)
raise ValueError(
f"expected_count={expected_count}, but the pattern matches {len(occurrences)} occurrence(s) - "
f"NO changes were applied. Review the prospective changes below; re-issue with the corrected "
f"expectation, a refined pattern, or occurrence_ids selecting the intended subset.\n{listing}"
)
ambiguous = [o for o in occurrences if o.is_ambiguous]
if ambiguous:
listing = self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=False)
raise ValueError(
f"{len(ambiguous)} occurrence(s) are ambiguous (the pattern matches again inside the matched text, "
f"indicating possible over-matching) - NO changes were applied. Review the prospective changes below "
f"and either refine the pattern or explicitly select occurrences via occurrence_ids.\n{listing}"
)
return self._apply_occurrences(replacer, occurrences, contents, needle, repl)
def _collect_files(self, relative_path: str, paths_include_glob: str, paths_exclude_glob: str) -> list[tuple[str, str]]:
"""Collects (relative_path, content) pairs of the non-ignored files in scope, in sorted path order."""
relative_path = relative_path.strip()
if relative_path:
self.project.validate_relative_path(relative_path, require_not_ignored=True)
abs_path = os.path.join(self.get_project_root(), relative_path)
if not os.path.exists(abs_path):
raise FileNotFoundError(f"Relative path {relative_path} does not exist.")
if os.path.isfile(abs_path):
rel_paths = [relative_path]
else:
_dirs, rel_paths = scan_directory(
path=abs_path,
recursive=True,
is_ignored_dir=self.project.is_ignored_path,
is_ignored_file=self.project.is_ignored_path,
relative_to=self.get_project_root(),
)
include_glob_matcher = GlobMatcher(paths_include_glob.strip()) if paths_include_glob.strip() else None
exclude_glob_matcher = GlobMatcher(paths_exclude_glob.strip()) if paths_exclude_glob.strip() else None
files: list[tuple[str, str]] = []
for path in sorted(rel_paths):
if include_glob_matcher and not include_glob_matcher.matches(path):
continue
if exclude_glob_matcher and exclude_glob_matcher.matches(path):
continue
try:
files.append((path, self.project.read_file(path)))
except Exception:
continue # skip unreadable (e.g. binary) files
return files
def _render_listing(
self,
replacer: MultiFileContentReplacer,
occurrences: list[ReplacementOccurrence],
contents: dict[str, str],
max_answer_chars: int,
dry_run: bool,
) -> str:
affected_files = sorted({o.relative_path for o in occurrences})
header = f"Found {len(occurrences)} occurrence(s) in {len(affected_files)} file(s)."
if dry_run:
header += (
" DRY RUN - no changes were applied.\n"
"Re-issue with dry_run=False to replace all of them, or additionally pass occurrence_ids "
"with the ids of the occurrences to replace."
)
parts = [header]
for path in affected_files:
file_occurrences = [o for o in occurrences if o.relative_path == path]
parts.append(f"\n{path} ({len(file_occurrences)} occurrence(s)):")
for occ in file_occurrences:
parts.append(replacer.render_occurrence_diff(occ, contents[path]))
result = "\n".join(parts)
def make_locations_only() -> str:
lines = [header] + [f" [{o.occurrence_id}] line {o.start_line}" for o in occurrences]
return "\n".join(lines)
def make_per_file_counts() -> str:
counts = {path: sum(1 for o in occurrences if o.relative_path == path) for path in affected_files}
return f"{header}\nOccurrence counts per file:\n{self._to_json(counts)}"
def make_summary() -> str:
return header
return self._limit_length(
result, max_answer_chars, shortened_result_factories=[make_locations_only, make_per_file_counts, make_summary]
)
@staticmethod
def _resolve_occurrence_ids(
occurrence_ids: list[str], occurrences: list[ReplacementOccurrence]
) -> tuple[list[ReplacementOccurrence], list[str]]:
"""Resolves the requested ids against the current occurrences, diagnosing each failure."""
occurrences_by_id = {o.occurrence_id: o for o in occurrences}
indices_by_path: dict[str, set[int]] = {}
for o in occurrences:
indices_by_path.setdefault(o.relative_path, set()).add(o.index_in_file)
selected: dict[str, ReplacementOccurrence] = {}
problems: list[str] = []
for oid in occurrence_ids:
occurrence = occurrences_by_id.get(oid)
if occurrence is not None:
selected[oid] = occurrence
continue
id_match = MultiFileContentReplacer.OCCURRENCE_ID_REGEX.match(oid)
if id_match is None:
problems.append(f"{oid}: malformed id (expected '<path>:<index>@<digest>' as returned by a dry run)")
elif id_match.group("path") not in indices_by_path:
problems.append(f"{oid}: the pattern currently has no matches in this file")
elif int(id_match.group("index")) not in indices_by_path[id_match.group("path")]:
problems.append(f"{oid}: the file now has fewer matches than at dry-run time (content changed)")
else:
problems.append(f"{oid}: the matched text changed since the dry run (content changed)")
return list(selected.values()), problems
def _apply_occurrences(
self,
replacer: MultiFileContentReplacer,
occurrences: list[ReplacementOccurrence],
contents: dict[str, str],
needle: str,
repl: str,
) -> str:
occurrences_by_file: dict[str, list[ReplacementOccurrence]] = {}
for occ in occurrences:
occurrences_by_file.setdefault(occ.relative_path, []).append(occ)
with self.DiagnosticsContext(self, *occurrences_by_file.keys()) as diagnostics_context:
code_editor = self.create_code_editor()
for path, file_occurrences in occurrences_by_file.items():
with EditedFileContext(path, code_editor) as context:
original_content = context.get_original_content()
if original_content != contents[path]:
# the editor's view differs from what was scanned (e.g. line-ending normalization);
# re-derive the occurrences from the authoritative content and re-validate by id
fresh_by_id = {o.occurrence_id: o for o in replacer.find_occurrences([(path, original_content)], needle, repl)}
try:
file_occurrences = [fresh_by_id[o.occurrence_id] for o in file_occurrences]
except KeyError as e:
raise ValueError(
f"The content of {path} changed while replacing (occurrence {e} no longer resolves); "
f"the file was NOT modified. Re-run with dry_run=True for current ids."
) from e
context.set_updated_content(replacer.apply_to_content(original_content, file_occurrences))
per_file = "\n".join(f" {path}: {len(occs)}" for path, occs in occurrences_by_file.items())
summary = f"Replaced {len(occurrences)} occurrence(s) in {len(occurrences_by_file)} file(s):\n{per_file}"
return diagnostics_context.format_result(summary)
class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional, EditApiMixin):
""" """
Deletes a range of lines within a file. Deletes a range of lines within a file.
""" """
@@ -469,13 +257,11 @@ class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional):
:param start_line: the 0-based index of the first line to be deleted :param start_line: the 0-based index of the first line to be deleted
:param end_line: the 0-based index of the last line to be deleted :param end_line: the 0-based index of the last line to be deleted
""" """
with self.DiagnosticsContext(self, relative_path) as diagnostics_context: with self.diagnostics_context(relative_path) as diagnostics_context:
code_editor = self.create_code_editor() return diagnostics_context.format_result(self._api().delete_lines(relative_path, start_line, end_line))
code_editor.delete_lines(relative_path, start_line, end_line)
return diagnostics_context.format_result(SUCCESS_RESULT)
class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional, EditApiMixin):
""" """
Replaces a range of lines within a file with new content. Replaces a range of lines within a file with new content.
""" """
@@ -497,19 +283,11 @@ class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional):
:param end_line: the 0-based index of the last line to be deleted :param end_line: the 0-based index of the last line to be deleted
:param content: the content to insert :param content: the content to insert
""" """
# normalizing the replacement content with self.diagnostics_context(relative_path) as diagnostics_context:
if not content.endswith("\n"): return diagnostics_context.format_result(self._api().replace_lines(relative_path, start_line, end_line, content))
content += "\n"
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.delete_lines(relative_path, start_line, end_line)
code_editor.insert_at_line(relative_path, start_line, content)
return diagnostics_context.format_result(SUCCESS_RESULT)
class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional, EditApiMixin):
""" """
Inserts content at a given line in a file. Inserts content at a given line in a file.
""" """
@@ -530,18 +308,11 @@ class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional):
:param line: the 0-based index of the line to insert content at :param line: the 0-based index of the line to insert content at
:param content: the content to be inserted :param content: the content to be inserted
""" """
# normalizing the inserted content with self.diagnostics_context(relative_path) as diagnostics_context:
if not content.endswith("\n"): return diagnostics_context.format_result(self._api().insert_at_line(relative_path, line, content))
content += "\n"
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.insert_at_line(relative_path, line, content)
return diagnostics_context.format_result(SUCCESS_RESULT)
class SearchForPatternTool(Tool): class SearchForPatternTool(Tool, FsApiMixin):
def apply( def apply(
self, self,
substring_pattern: str, substring_pattern: str,
@@ -573,92 +344,19 @@ class SearchForPatternTool(Tool):
``-1`` uses the configured default. ``-1`` uses the configured default.
:return: A mapping from file paths to matched consecutive lines (0-based line numbers). :return: A mapping from file paths to matched consecutive lines (0-based line numbers).
""" """
relative_path = relative_path.strip() return (
if relative_path: self._api()
self.project.validate_relative_path(relative_path) .search_for_pattern(
substring_pattern,
matches = self.project.search_project_files_for_pattern( context_lines_before=context_lines_before,
pattern=substring_pattern, context_lines_after=context_lines_after,
relative_path=relative_path, paths_include_glob=paths_include_glob,
context_lines_before=context_lines_before, paths_exclude_glob=paths_exclude_glob,
context_lines_after=context_lines_after, relative_path=relative_path,
paths_include_glob=paths_include_glob.strip(), restrict_search_to_code_files=restrict_search_to_code_files,
paths_exclude_glob=paths_exclude_glob.strip(), skip_ignored_files=skip_ignored_files,
multiline=multiline, multiline=multiline,
code_files_only=restrict_search_to_code_files, max_answer_chars=max_answer_chars,
skip_ignored_files=skip_ignored_files, )
.represent()
) )
# group matches by file
file_to_matches: dict[str, list[str]] = defaultdict(list)
for match in matches:
assert match.source_file_path is not None
file_to_matches[match.source_file_path].append(match.to_display_string())
# capture lightweight match data for shortening before serialization
match_lines_by_file: dict[str, list[dict[str, int | str]]] = defaultdict(list)
for match in matches:
assert match.source_file_path is not None
first = match.matched_lines[0]
match_lines_by_file[match.source_file_path].append({"line": first.line_number, "text": first.line_content.strip()})
# shortened result closures, from least to most aggressive shortening
_TEXT_TRUNCATE = 60
def render_first_lines(truncate: bool) -> str:
"""Render each match's first line, either in full or truncated to a fixed length."""
def entry_text(text: str) -> str:
if truncate and len(text) > _TEXT_TRUNCATE:
return text[:_TEXT_TRUNCATE] + "..."
return text
compact = {
path: [{"line": m["line"], "text": entry_text(str(m["text"]))} for m in lines]
for path, lines in match_lines_by_file.items()
}
if truncate:
header = (
f"Matched lines (text over {_TEXT_TRUNCATE} chars is truncated, marked with a trailing '...'); "
"use read_file with the line numbers for full content:"
)
else:
header = "Matched lines per file; use read_file with the line numbers for surrounding context:"
return f"{header}\n{self._to_json(compact)}"
def make_first_lines_full() -> str:
"""Match locations with each match's full first line."""
return render_first_lines(truncate=False)
def make_first_lines_truncated() -> str:
"""Match locations with each match's first line truncated to a fixed length."""
return render_first_lines(truncate=True)
def make_line_numbers_only() -> str:
"""Match locations as bare line numbers (no text)."""
numbers = {path: [m["line"] for m in lines] for path, lines in match_lines_by_file.items()}
return f"Match lines per file:\n{self._to_json(numbers)}"
def make_per_file_counts() -> str:
counts = {path: len(lines) for path, lines in match_lines_by_file.items()}
return f"Match counts per file:\n{self._to_json(counts)}"
def make_summary() -> str:
return f"Found {len(matches)} matches in {len(match_lines_by_file)} files."
result = self._to_json(file_to_matches)
return self._limit_length(
result,
max_answer_chars,
shortened_result_factories=[
make_first_lines_full,
make_first_lines_truncated,
make_line_numbers_only,
make_per_file_counts,
make_summary,
],
)
"""
Performs a search for a pattern in the project.
"""
+80 -315
View File
@@ -1,29 +1,40 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import logging import logging
from collections import Counter from typing import TYPE_CHECKING, Literal, cast
from typing import Any, Literal
import serena.jetbrains.jetbrains_types as jb from serena.symbol import SymbolDictGrouper
from serena.code_editor import JetBrainsCodeEditor from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOUtil if TYPE_CHECKING:
from serena.symbol import JetBrainsSymbolDictGrouper from serena.repl.api.jb_api import JetBrainsApi
from serena.tools import Tool, ToolMarkerBeta, ToolMarkerOptional, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead
from serena.util.text_utils import find_text_coordinates
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsApiMixin:
"""
Mixin for tools which delegate to the JetBrains API.
The API is imported locally, since the API module refers to the tools (as corresponding tools).
"""
def _api(self) -> "JetBrainsApi":
from serena.repl.api.jb_api import JetBrainsApi
tool = cast(Tool, cast(object, self))
return JetBrainsApi(tool.agent)
class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Performs a global (or local) search for symbols using the JetBrains backend Performs a global (or local) search for symbols using the JetBrains backend
""" """
# groups top-level symbols only; children are grouped separately by _group_children_by_type @property
symbol_dict_grouper = JetBrainsSymbolDictGrouper( def symbol_dict_grouper(self) -> SymbolDictGrouper:
["relative_path", "type"], ["type"], collapse_singleton=True, map_name_path_to_name=True from serena.repl.api.jb_api import JetBrainsApi
)
return JetBrainsApi.find_symbol_grouper_
def apply( def apply(
self, self,
@@ -75,76 +86,40 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
:return: symbols matching the name. :return: symbols matching the name.
""" """
# check input
# - pattern with only wildcards is invalid, but in some cases we delegate to the overview tool
if name_path_pattern.replace("*", "").replace("/", "") == "":
if relative_path:
if self.project.relative_path_exists(relative_path, require_file=True):
overview_tool = self.agent.get_tool(JetBrainsGetSymbolsOverviewTool)
overview_response = overview_tool.apply(relative_path, depth=depth)
return self._wrapped_tool_response(
overview_response, f"Wildcard-only pattern not admitted; used {overview_tool.get_name()} instead"
)
raise ValueError("name_path_pattern must not be empty or contain only wildcards; consider using the overview tool")
if include_body:
depth = 0 # ignore user-specified depth if body is requested
name_path_pattern = self._sanitize_input_param(name_path_pattern) name_path_pattern = self._sanitize_input_param(name_path_pattern)
if relative_path: if relative_path:
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
if relative_path == ".":
relative_path = None
if relative_path is not None and relative_path.startswith(jb.JB_EXTERNAL_FILE_PREFIX): # for a wildcard-only pattern restricted to a file, delegate to the overview tool
search_deps = True if name_path_pattern.replace("*", "").replace("/", "") == "" and relative_path:
if self.project.relative_path_exists(relative_path, require_file=True):
overview_tool = self.agent.get_tool(JetBrainsGetSymbolsOverviewTool)
overview_response = overview_tool.apply(relative_path, depth=depth)
return self._wrapped_tool_response(
overview_response, f"Wildcard-only pattern not admitted; used {overview_tool.get_name()} instead"
)
with JetBrainsPluginClient.from_project(self.project) as client: return (
if include_body: self._api()
include_quick_info = False .find_symbol(
include_documentation = False name_path_pattern,
else:
if include_info:
include_documentation = True
include_quick_info = False
else:
# If no additional information is requested, we still include the quick info (type signature)
include_documentation = False
include_quick_info = True
symbol_collection_response = client.find_symbol(
name_path=name_path_pattern,
relative_path=relative_path,
depth=depth, depth=depth,
relative_path=relative_path,
include_body=include_body, include_body=include_body,
include_documentation=include_documentation, include_info=include_info,
include_quick_info=include_quick_info,
search_deps=search_deps, search_deps=search_deps,
max_matches=max_matches,
max_answer_chars=max_answer_chars,
) )
symbols = symbol_collection_response["symbols"] .represent()
)
def create_shortened_result() -> str:
"""Shortened results containing symbol types and identifiers (path + name_path) only, without children"""
dicts: list[SymbolDTO] = [
{"name_path": s["name_path"], "type": s["type"], "relative_path": s["relative_path"]} for s in symbols
]
grouped = self.symbol_dict_grouper.group(dicts)
return f"Names with paths:\n{self._to_json(grouped)}"
n_matches = len(symbols)
if 0 < max_matches < n_matches:
return f"Matched {n_matches}>{max_matches=} symbols.\n" + create_shortened_result()
grouped_symbols = self.symbol_dict_grouper.group(symbols)
result = self._to_json(grouped_symbols)
return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_shortened_result])
@classmethod @classmethod
def get_param_aliases(cls) -> dict[str, str]: def get_param_aliases(cls) -> dict[str, str]:
return {"name_path": "name_path_pattern"} return {"name_path": "name_path_pattern"}
class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Moves a symbol, file or directory to a new location using the JetBrains backend, updating all references Moves a symbol, file or directory to a new location using the JetBrains backend, updating all references
""" """
@@ -180,21 +155,11 @@ class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMa
:param target_relative_path: the relative path of the target directory or file. :param target_relative_path: the relative path of the target directory or file.
:param target_parent_name_path: the name path of the target parent symbol. :param target_parent_name_path: the name path of the target parent symbol.
""" """
name_path = name_path or None
target_relative_path = target_relative_path or None
target_parent_name_path = target_parent_name_path or None
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().move(relative_path, name_path, target_relative_path, target_parent_name_path).represent()
response_dict = client.move(
name_path=name_path,
relative_path=relative_path,
target_parent_name_path=target_parent_name_path,
target_relative_path=target_relative_path,
)
return self._to_json(response_dict)
class JetBrainsSafeDeleteTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): class JetBrainsSafeDeleteTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Safely deletes a symbol using the JetBrains backend, checking for remaining usages first Safely deletes a symbol using the JetBrains backend, checking for remaining usages first
""" """
@@ -223,18 +188,10 @@ class JetBrainsSafeDeleteTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional,
remove symbols that become unused after the deletion. Default is False. remove symbols that become unused after the deletion. Default is False.
""" """
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
name_path = name_path or None return self._api().safe_delete(relative_path, name_path, delete_even_if_used, propagate).represent()
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.safe_delete(
name_path=name_path,
relative_path=relative_path,
delete_even_if_used=delete_even_if_used,
propagate=propagate,
)
return self._to_json(response_dict)
class JetBrainsInlineSymbol(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): class JetBrainsInlineSymbol(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Inlines a symbol using the JetBrains backend, replacing all call sites with the symbol's body Inlines a symbol using the JetBrains backend, replacing all call sites with the symbol's body
""" """
@@ -258,21 +215,19 @@ class JetBrainsInlineSymbol(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, To
May be ignored in some cases (e.g. when inlining a class). May be ignored in some cases (e.g. when inlining a class).
""" """
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().inline_symbol(name_path, relative_path, keep_definition).represent()
response_dict = client.inline_symbol(
name_path=name_path,
relative_path=relative_path,
keep_definition=keep_definition,
)
return self._to_json(response_dict)
class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Finds symbols that reference the given symbol using the JetBrains backend Finds symbols that reference the given symbol using the JetBrains backend
""" """
symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True) @property
def symbol_dict_grouper(self) -> SymbolDictGrouper:
from serena.repl.api.jb_api import JetBrainsApi
return JetBrainsApi.references_grouper_
def apply( def apply(
self, self,
@@ -292,52 +247,19 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
""" """
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().find_referencing_symbols(name_path, relative_path, max_answer_chars).represent()
response_dict = client.find_references(
name_path=name_path,
relative_path=relative_path,
include_quick_info=False,
)
symbol_dicts = response_dict["symbols"]
# replace reference line number (if present) by actual line/context
for symbol_dict in symbol_dicts:
if "reference_line_no" in symbol_dict:
ref_line = symbol_dict["reference_line_no"]
ref_relative_path = symbol_dict["relative_path"]
if not SymbolDTOUtil.is_external_symbol(symbol_dict) and ref_line is not None and ref_line >= 0:
content_around_ref = self.project.retrieve_content_around_line(
relative_file_path=ref_relative_path, line=ref_line, context_lines_before=1, context_lines_after=1
)
symbol_dict["context"] = content_around_ref.to_display_string()
del symbol_dict["reference_line_no"]
# capture file paths before grouping
ref_paths = [s.get("relative_path", "unknown") for s in symbol_dicts]
result = self.symbol_dict_grouper.group(symbol_dicts)
def create_shortened_result_counts_per_file() -> str:
return f"Reference counts per file:\n{self._to_json(Counter(ref_paths))}"
def create_shortened_result_num_results() -> str:
return f"Found {len(ref_paths)} references."
result_json = self._to_json(result)
return self._limit_length(
result_json,
max_answer_chars,
shortened_result_factories=[create_shortened_result_counts_per_file, create_shortened_result_num_results],
)
class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Retrieves an overview of the top-level symbols within a specified file using the JetBrains backend Retrieves an overview of the top-level symbols within a specified file using the JetBrains backend
""" """
USE_COMPACT_FORMAT = True @property
symbol_dict_grouper = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True) def symbol_dict_grouper(self) -> SymbolDictGrouper:
from serena.repl.api.jb_api import JetBrainsApi
return JetBrainsApi.overview_grouper_
def apply( def apply(
self, self,
@@ -357,95 +279,15 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
:param include_file_documentation: whether to include the file's docstring. Default False. :param include_file_documentation: whether to include the file's docstring. Default False.
""" """
if depth == -1:
if relative_path.endswith((".java", ".kt")):
depth = 1
else:
depth = 0
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().get_symbols_overview(relative_path, depth, max_answer_chars, include_file_documentation).represent()
symbol_overview = client.get_symbols_overview(
relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation
)
if self.USE_COMPACT_FORMAT:
symbols = symbol_overview["symbols"]
grouped_symbols = self.symbol_dict_grouper.group(symbols)
shortened_result_factories = []
# create full result
result: dict[str, Any] = {"symbols": grouped_symbols}
documentation = symbol_overview.pop("documentation", None)
if documentation:
result["docstring"] = documentation
shortened_result_factories.append(lambda: self._to_json(grouped_symbols)) # shortened result without docstring
json_result = self._to_json(result)
if depth > 0:
def create_short_result_depth_0() -> str:
depth_0_symbols = [d.copy() for d in symbols]
for d in depth_0_symbols:
d.pop("children", None)
compact_depth_0_result = self.symbol_dict_grouper.group(depth_0_symbols)
return "Depth 0 overview:\n" + self._to_json(compact_depth_0_result)
shortened_result_factories.append(create_short_result_depth_0)
def create_short_result_type_counts() -> str:
type_names = [d.get("type", "unknown") for d in symbols]
return f"Symbol counts by type:\n{self._to_json(Counter(type_names))}"
shortened_result_factories.append(create_short_result_type_counts)
else:
# this path is currently abandoned, consider introducing shortened results if ever needed
shortened_result_factories = None
json_result = self._to_json(symbol_overview)
return self._limit_length(json_result, max_answer_chars, shortened_result_factories=shortened_result_factories)
class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Retrieves the type hierarchy (supertypes and/or subtypes) of a symbol using the JetBrains backend Retrieves the type hierarchy (supertypes and/or subtypes) of a symbol using the JetBrains backend
""" """
@staticmethod
def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]:
"""
Transform a list of TypeHierarchyNode into a file-grouped compact format.
Returns a dict where keys are relative_paths and values are lists of either:
- "SymbolNamePath" (leaf node)
- {"SymbolNamePath": {nested_file_grouped_children}} (node with children)
"""
if not nodes:
return {}
result: dict[str, list] = {}
for node in nodes:
symbol = node["symbol"]
name_path = symbol["name_path"]
rel_path = symbol["relative_path"]
children = node.get("children", [])
if rel_path not in result:
result[rel_path] = []
if children:
# Node with children - recurse
nested = JetBrainsTypeHierarchyTool._transform_hierarchy_nodes(children)
result[rel_path].append({name_path: nested})
else:
# Leaf node
result[rel_path].append(name_path)
return result
def apply( def apply(
self, self,
name_path: str, name_path: str,
@@ -460,51 +302,17 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona
:param name_path: name path of the symbol for which to get the type hierarchy. :param name_path: name path of the symbol for which to get the type hierarchy.
:param relative_path: the relative path to the file containing the symbol. :param relative_path: the relative path to the file containing the symbol.
:param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces, :param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces,
"sub" for subclasses/implementations, or "both" for both directions. Default is "sub". "sub" for subclasses/implementations, or "both" for both directions. Default is "both".
:param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1. :param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1.
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used. -1 means the default value from the config will be used.
:return: Compact JSON with file-grouped hierarchy. Error string if not applicable. :return: Compact JSON with file-grouped hierarchy. Error string if not applicable.
""" """
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().get_type_hierarchy(name_path, relative_path, hierarchy_type, depth, max_answer_chars).represent()
subtypes = None
supertypes = None
levels_not_included = {}
if hierarchy_type in ("super", "both"):
supertypes_response = client.get_supertypes(
name_path=name_path,
relative_path=relative_path,
depth=depth,
)
if "num_levels_not_included" in supertypes_response:
levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"]
supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy"))
if hierarchy_type in ("sub", "both"):
subtypes_response = client.get_subtypes(
name_path=name_path,
relative_path=relative_path,
depth=depth,
)
if "num_levels_not_included" in subtypes_response:
levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"]
subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy"))
result_dict: dict[str, dict | list] = {}
if supertypes is not None:
result_dict["supertypes"] = supertypes
if subtypes is not None:
result_dict["subtypes"] = subtypes
if levels_not_included:
result_dict["levels_not_included"] = levels_not_included
result = self._to_json(result_dict)
return self._limit_length(result, max_answer_chars)
class JetBrainsFindDeclarationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsFindDeclarationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Finds the declaration of a symbol using the JetBrains backend Finds the declaration of a symbol using the JetBrains backend
""" """
@@ -523,20 +331,10 @@ class JetBrainsFindDeclarationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptio
""" """
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
regex = self._sanitize_input_param(regex) regex = self._sanitize_input_param(regex)
return self._api().find_declaration(relative_path, regex, include_body).represent()
editor = self.create_code_editor()
content = editor.read_file(relative_path)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
with JetBrainsPluginClient.from_project(self.project) as client:
symbol_collection = client.find_declaration(
relative_path=relative_path, line=coords.line, col=coords.col, include_quick_info=False, include_body=include_body
)
result = self._to_json(symbol_collection)
return result
class JetBrainsFindImplementationsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsFindImplementationsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Finds the implementations of a symbol using the JetBrains backend Finds the implementations of a symbol using the JetBrains backend
""" """
@@ -548,17 +346,10 @@ class JetBrainsFindImplementationsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerO
:param relative_path: the relative path to the source file containing the symbol for which to find implementations. :param relative_path: the relative path to the source file containing the symbol for which to find implementations.
:param name_path: name path of the symbol for which to find implementations :param name_path: name path of the symbol for which to find implementations
""" """
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().find_implementations(relative_path, name_path).represent()
symbol_collection = client.find_implementations(
relative_path=relative_path,
name_path=name_path,
include_quick_info=False,
)
result = self._to_json(symbol_collection)
return result
class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional): class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Renames a symbol, file or directory throughout the codebase using the JetBrains backend. Renames a symbol, file or directory throughout the codebase using the JetBrains backend.
""" """
@@ -584,18 +375,10 @@ class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional):
:param rename_in_text_occurrences: whether to also rename occurrences in text. Default True. :param rename_in_text_occurrences: whether to also rename occurrences in text. Default True.
:return: a status message :return: a status message
""" """
code_editor = JetBrainsCodeEditor(self.project) return self._api().rename(relative_path, new_name, name_path, rename_in_comments, rename_in_text_occurrences).represent()
result = code_editor.rename_symbol(
name_path=name_path,
relative_path=relative_path,
new_name=new_name,
rename_in_comments=rename_in_comments,
rename_in_text_occurrences=rename_in_text_occurrences,
)
return self._to_json(result)
class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): class JetBrainsDebugTool(Tool, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation) Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation)
via a persistent debug REPL connected to the JetBrains IDE. via a persistent debug REPL connected to the JetBrains IDE.
@@ -617,15 +400,10 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta):
:param repl_key: identifier for the REPL instance. State persists across calls with the same key. :param repl_key: identifier for the REPL instance. State persists across calls with the same key.
:return: string representation of the result :return: string representation of the result
""" """
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().debug_eval(expression, repl_key)
if expression:
response = client.debug_eval(repl_key=repl_key, expression=expression)
else:
response = client.debug_close(repl_key=repl_key)
return response.get("result", str(response))
class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Runs JetBrains IDE inspections on a file and returns the results. Runs JetBrains IDE inspections on a file and returns the results.
""" """
@@ -655,19 +433,12 @@ class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption
-1 means the default value from the config will be used. -1 means the default value from the config will be used.
:return: JSON string with inspection results including severity, message, and location. :return: JSON string with inspection results including severity, message, and location.
""" """
with JetBrainsPluginClient.from_project(self.project) as client: return (
response_dict = client.run_inspections( self._api().run_inspections(relative_path, min_severity, inspection_names, start_line, end_line, max_answer_chars).represent()
relative_path=relative_path, )
min_severity=min_severity,
inspection_names=inspection_names,
start_line=start_line,
end_line=end_line,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, JetBrainsApiMixin):
""" """
Lists available JetBrains IDE inspections, optionally filtered by language or group. Lists available JetBrains IDE inspections, optionally filtered by language or group.
""" """
@@ -689,10 +460,4 @@ class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptio
-1 means the default value from the config will be used. -1 means the default value from the config will be used.
:return: JSON string with the list of available inspections including name, group path, and language. :return: JSON string with the list of available inspections including name, group path, and language.
""" """
with JetBrainsPluginClient.from_project(self.project) as client: return self._api().list_inspections(language, group_path_contains, max_answer_chars).represent()
response_dict = client.list_inspections(
language=language,
group_path_contains=group_path_contains,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
+28 -30
View File
@@ -1,14 +1,27 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import logging from typing import TYPE_CHECKING, Literal, cast
from typing import Literal
from serena.tools import Tool, ToolMarkerCanEdit from serena.tools import Tool, ToolMarkerCanEdit
log = logging.getLogger(__name__) if TYPE_CHECKING:
from serena.repl.api.mem_api import MemoryApi
class WriteMemoryTool(Tool, ToolMarkerCanEdit): class MemoryApiMixin:
"""
Mixin for tools which delegate to the memory API.
The API is imported locally, since the API module refers to the tools (as corresponding tools).
"""
def _api(self) -> "MemoryApi":
from serena.repl.api.mem_api import MemoryApi
tool = cast(Tool, cast(object, self))
return MemoryApi(tool.agent)
class WriteMemoryTool(Tool, ToolMarkerCanEdit, MemoryApiMixin):
""" """
Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
The memory name should be meaningful. The memory name should be meaningful.
@@ -26,18 +39,10 @@ class WriteMemoryTool(Tool, ToolMarkerCanEdit):
:param content: memory content, utf8-encoded :param content: memory content, utf8-encoded
:param max_chars: see other tools :param max_chars: see other tools
""" """
# NOTE: utf-8 encoding is configured in the MemoriesManager return self._api().write_memory(memory_name, content, max_chars)
if max_chars == -1:
max_chars = self.agent.serena_config.default_max_tool_answer_chars
if len(content) > max_chars:
raise ValueError(
f"Content for {memory_name} is too long. Max length is {max_chars} characters. " + "Please make the content shorter."
)
return self.memory_manager.save_memory(memory_name, content, is_tool_context=True)
class ReadMemoryTool(Tool): class ReadMemoryTool(Tool, MemoryApiMixin):
""" """
Reads the content of a memory file. Reads the content of a memory file.
""" """
@@ -46,10 +51,10 @@ class ReadMemoryTool(Tool):
""" """
Use to read a memory that is likely to be relevant to the current task, inferring relevance e.g. from the name. Use to read a memory that is likely to be relevant to the current task, inferring relevance e.g. from the name.
""" """
return self.memory_manager.load_memory(memory_name) return self._api().read_memory(memory_name)
class ListMemoriesTool(Tool): class ListMemoriesTool(Tool, MemoryApiMixin):
""" """
Lists available memories. Lists available memories.
""" """
@@ -58,10 +63,10 @@ class ListMemoriesTool(Tool):
""" """
Lists available memories, optionally filtered by topic. Lists available memories, optionally filtered by topic.
""" """
return self._to_json(self.memory_manager.list_memories(topic).to_dict()) return self._api().list_memories(topic).represent()
class DeleteMemoryTool(Tool, ToolMarkerCanEdit): class DeleteMemoryTool(Tool, ToolMarkerCanEdit, MemoryApiMixin):
""" """
Delete a memory file. Delete a memory file.
""" """
@@ -70,10 +75,10 @@ class DeleteMemoryTool(Tool, ToolMarkerCanEdit):
""" """
Delete a memory, only call if instructed explicitly or permission was granted by the user. Delete a memory, only call if instructed explicitly or permission was granted by the user.
""" """
return self.memory_manager.delete_memory(memory_name, is_tool_context=True) return self._api().delete_memory(memory_name)
class RenameMemoryTool(Tool, ToolMarkerCanEdit): class RenameMemoryTool(Tool, ToolMarkerCanEdit, MemoryApiMixin):
""" """
Renames or moves a memory, updating references that are marked with the `mem:` prefix. Renames or moves a memory, updating references that are marked with the `mem:` prefix.
""" """
@@ -85,15 +90,10 @@ class RenameMemoryTool(Tool, ToolMarkerCanEdit):
References to other memories that are marked with the `mem:` prefix will be updated accordingly. References to other memories that are marked with the `mem:` prefix will be updated accordingly.
References in read-only memories are not affected. References in read-only memories are not affected.
""" """
renaming_message, n_references_updated = self.memory_manager.rename_memory_and_propagate_references( return self._api().rename_memory(old_name, new_name)
old_name, new_name, is_tool_context=True
)
if n_references_updated > 0:
log.info(f"Updated {n_references_updated} references to memory {old_name} to {new_name}")
return renaming_message
class EditMemoryTool(Tool, ToolMarkerCanEdit): class EditMemoryTool(Tool, ToolMarkerCanEdit, MemoryApiMixin):
""" """
Replaces content matching a regular expression in a memory. Replaces content matching a regular expression in a memory.
""" """
@@ -119,6 +119,4 @@ class EditMemoryTool(Tool, ToolMarkerCanEdit):
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences. :param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
If false and multiple occurrences are found, an error will be returned. If false and multiple occurrences are found, an error will be returned.
""" """
return self.memory_manager.edit_memory( return self._api().edit_memory(memory_name, needle, repl, mode, allow_multiple_occurrences)
memory_name, needle, repl, mode, allow_multiple_occurrences, is_tool_context=True, regex_multiline=True
)
+8 -12
View File
@@ -2,7 +2,6 @@
import json import json
from serena.config.serena_config import LanguageBackend
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager
from serena.project_server import ProjectServerClient from serena.project_server import ProjectServerClient
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
@@ -57,7 +56,7 @@ class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveP
tool = self.agent.get_tool_by_name(tool_name) tool = self.agent.get_tool_by_name(tool_name)
assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project." assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project."
if self._is_project_server_required(tool): if self._is_project_server_required(tool):
client = ProjectServerClient() client = ProjectServerClient(self.agent.serena_config)
return client.query_project(project_name, tool_name, tool_params_json) return client.query_project(project_name, tool_name, tool_params_json)
else: else:
registered_project = self.agent.serena_config.get_registered_project(project_name) registered_project = self.agent.serena_config.get_registered_project(project_name)
@@ -67,13 +66,10 @@ class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveP
return tool.apply(**json.loads(tool_params_json)) return tool.apply(**json.loads(tool_params_json))
def _is_project_server_required(self, tool: Tool) -> bool: def _is_project_server_required(self, tool: Tool) -> bool:
match self.agent.get_language_backend(): # The project server is relevant to the LSP backend only
case LanguageBackend.JETBRAINS: if not self.agent.get_language_backend().is_lsp():
return False return False
case LanguageBackend.LSP: # Note: As long as only read-only tools are considered, only symbolic tools require the project server.
# Note: As long as only read-only tools are considered, only symbolic tools require the project server. # But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers.
# But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers. assert tool.is_readonly()
assert tool.is_readonly() return tool.is_symbolic()
return tool.is_symbolic()
case _:
raise NotImplementedError
+54
View File
@@ -0,0 +1,54 @@
"""
Tools which provide access to Serena's functionality through Python code execution
"""
# SPDX-License-Identifier: GPL-3.0-or-later
from serena.tools.tools_base import Tool, ToolMarkerBeta, ToolMarkerOptional
class SerenaReplTool(Tool, ToolMarkerOptional, ToolMarkerBeta):
"""
Executes Python code which accesses Serena's functionality programmatically.
"""
def get_apply_docstring(self) -> str:
docs = self.get_apply_docstring_from_cls()
if self.agent.is_single_project():
docs += "\n\nAvailable facades:\n" + self.agent.get_repl().entrypoint.overview()
else:
docs += "\n\nAvailable facades are provided at project activation"
return docs
def apply(self, session_id: str, code: str) -> str:
"""
Executes the given Python code, which has access to Serena's functionality through the object `s`.
The functionality is organised in facades, which are attributes of `s` (e.g. `s.myfacade`).
Documentation: Use `s.info("<facade>")` when you will use a facade's functionality (it documents all common
operations at once) and `s.info("<facade>.<method>")` for a single or a rarely needed operation. Several items
can be requested in one call, e.g. `s.info("lsp", "edit.replace_content")`.
`s.info("<facade>")` documents the facade's operations only, not their result types. The facade listing
provides result types (`method -> Type`); request their documentation via `s.info("<Type>")`, which
includes the types they contain, ONLY if you intend to process results in code (filter, aggregate, chain
calls).
The code is executed like a notebook cell: if its last statement is an expression, the expression's value is
the result. Results are rendered in a form suitable for you; lists are rendered element-wise,
strings are passed through unchanged.
Output size: methods with a `max_answer_chars` parameter limit the size of the rendered result (-1 uses the
configured default). If the limit is exceeded, a shortened result (or no content) is rendered instead;
adjust the limit only if there is no other way to obtain the content required for the task (e.g. by
narrowing the query or processing the result in code and returning only what is needed).
Persistence: variables, functions and classes defined at the top level of your code persist across calls
within your session (like the cells of a notebook), so you can reuse results and define helper functions once.
`s.vars()` lists the persisted items, `s.clear()` removes them. Do not store facades (`s.<facade>`) in
variables; access them via `s` at call time. Do not keep large results longer than needed.
:param session_id: your Serena session id, as provided in Serena's instructions (call `initial_instructions` if you do not have one)
:param code: the Python code to execute
:return: the representation of the returned value, or the error if execution failed
"""
return self.agent.get_repl().execute(code, self.agent.get_session(session_id))
+126 -397
View File
@@ -3,43 +3,55 @@ Language server-related tools
""" """
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import copy from typing import TYPE_CHECKING, cast
import os
from collections import Counter, defaultdict
from collections.abc import Sequence
from typing import Any
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.symbol import SymbolDictGrouper
from serena.tools import ( from serena.tools import (
SUCCESS_RESULT,
EditingToolWithDiagnostics, EditingToolWithDiagnostics,
Tool, Tool,
ToolMarkerSymbolicEdit, ToolMarkerSymbolicEdit,
ToolMarkerSymbolicRead, ToolMarkerSymbolicRead,
) )
from serena.tools.file_tools import EditApiMixin
from serena.tools.tools_base import ToolMarkerOptional from serena.tools.tools_base import ToolMarkerOptional
from serena.util.ls_diagnostics import GroupedDiagnostics
from serena.util.text_utils import find_text_coordinates if TYPE_CHECKING:
from solidlsp.ls_types import SymbolKind from serena.repl.api.lsp_api import LspApi
class RestartLanguageServerTool(Tool, ToolMarkerOptional): class LspApiMixin:
"""
Mixin for tools which delegate to the language server API.
The API is imported locally, since the API module refers to the tools (as corresponding tools).
"""
def _api(self) -> "LspApi":
from serena.repl.api.lsp_api import LspApi
tool = cast(Tool, cast(object, self))
return LspApi(tool.agent)
class RestartLanguageServerTool(Tool, ToolMarkerOptional, LspApiMixin):
"""Restarts the language server(s).""" """Restarts the language server(s)."""
def apply(self) -> str: def apply(self) -> str:
"""Use this tool only on explicit user request or after confirmation. """Use this tool only on explicit user request or after confirmation.
It may be necessary to restart the language server if it hangs. It may be necessary to restart the language server if it hangs.
""" """
self.agent.reset_language_server_manager() return self._api().restart_language_server()
return SUCCESS_RESULT
class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, LspApiMixin):
""" """
Gets an overview of the top-level symbols defined in a given file. Gets an overview of the top-level symbols defined in a given file.
""" """
symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True) @property
def symbol_dict_grouper(self) -> SymbolDictGrouper:
from serena.repl.api.lsp_api import LspApi
return LspApi.overview_grouper_
def apply(self, relative_path: str, depth: int = -1, max_answer_chars: int = -1) -> str: def apply(self, relative_path: str, depth: int = -1, max_answer_chars: int = -1) -> str:
""" """
@@ -55,93 +67,20 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
Don't adjust unless there is really no other way to get the content required for the task. Don't adjust unless there is really no other way to get the content required for the task.
:return: a JSON object containing symbols grouped by kind in a compact format. :return: a JSON object containing symbols grouped by kind in a compact format.
""" """
# Note: file system sync not required (relevant file is opened in the language server explicitly) return self._api().get_symbols_overview(relative_path, depth=depth, max_answer_chars=max_answer_chars).represent()
if depth == -1:
if relative_path.endswith((".java", ".kt")):
depth = 1
else:
depth = 0
result = self.get_symbol_overview(relative_path, depth=depth)
# capture kind names and depth-0 snapshots before grouping, which mutates the dicts
kind_names = [d.get("kind", "unknown") for d in result]
if depth > 0:
depth_0_result = [d.copy() for d in result]
for d in depth_0_result:
d.pop("children", None)
compact_result = self.symbol_dict_grouper.group(result)
result_json_str = self._to_json(compact_result)
# shortened result closures
def make_kind_counts() -> str:
return f"Symbol counts by kind:\n{self._to_json(Counter(kind_names))}"
if depth == 0:
shortened_results = [make_kind_counts]
else:
def make_depth_0_result() -> str:
compact_depth_0_result = self.symbol_dict_grouper.group(depth_0_result)
return "Depth 0 overview:\n" + self._to_json(compact_depth_0_result)
shortened_results = [make_depth_0_result, make_kind_counts]
return self._limit_length(result_json_str, max_answer_chars, shortened_result_factories=shortened_results)
def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[LanguageServerSymbol.OutputDict]:
"""
:param relative_path: relative path to a source file
:param depth: the depth up to which descendants shall be retrieved
:return: a list of symbol dictionaries representing the symbol overview of the file
"""
symbol_retriever = self.create_language_server_symbol_retriever()
# The symbol overview is capable of working with both files and directories,
# but we want to ensure that the user provides a file path.
file_path = os.path.join(self.project.project_root, relative_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.")
if os.path.isdir(file_path):
raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ")
if not symbol_retriever.can_analyze_file(relative_path):
raise ValueError(
f"Cannot extract symbols from file {relative_path}. Active language servers: {[l.value for l in self.agent.get_active_language_server_ids()]}"
)
symbols = symbol_retriever.get_symbol_overview(relative_path)[relative_path]
def child_inclusion_predicate(s: LanguageServerSymbol) -> bool:
return not s.is_low_level()
symbol_dicts = []
for symbol in symbols:
symbol_dicts.append(
symbol.to_dict(
name_path=False,
name=True,
depth=depth,
kind=True,
relative_path=False,
location=False,
child_inclusion_predicate=child_inclusion_predicate,
)
)
return symbol_dicts
class FindSymbolTool(Tool, ToolMarkerSymbolicRead): class FindSymbolTool(Tool, ToolMarkerSymbolicRead, LspApiMixin):
""" """
Performs a global (or local) search using the language server backend. Performs a global (or local) search using the language server backend.
""" """
# group children by kind, keeping just the name (the parent's name_path makes it unambiguous); @property
# we don't group the top-level result list because many tests rely on it being a flat list of symbol dicts def symbol_dict_grouper(self) -> SymbolDictGrouper:
symbol_dict_grouper = LanguageServerSymbolDictGrouper([], ["kind"], collapse_singleton=True) from serena.repl.api.lsp_api import LspApi
return LspApi.find_symbol_dict_grouper_
# noinspection PyDefaultArgument
def apply( def apply(
self, self,
name_path_pattern: str, name_path_pattern: str,
@@ -179,85 +118,54 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
:param relative_path: (optional) restrict search to this file or directory. If None, searches entire codebase. :param relative_path: (optional) restrict search to this file or directory. If None, searches entire codebase.
If a directory is passed, the search will be restricted to the files in that directory. If a directory is passed, the search will be restricted to the files in that directory.
If a file is passed, the search will be restricted to that file. If a file is passed, the search will be restricted to that file.
:param include_body: whether to include the symbol's source code. Use judiciously. If you have some knowledge about the codebase, you should use this parameter, as it will significantly
speed up the search as well as reduce the number of results.
:param include_body: If True, include the symbol's source code. Use judiciously.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature), :param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the symbol (ignored if include_body is True). Info is never included for child symbols. about the symbol (ignored if include_body is True). Info is never included for child symbols.
Note: Depending on the language, this can be slow (e.g., C/C++). Note: Depending on the language, this can be slow (e.g., C/C++).
:param include_kinds: (optional) limits results to the given LSP symbol kinds (integers) :param include_kinds: (optional) limits results to the given LSP symbol kinds (integers)
:param exclude_kinds: (optional) list of LSP symbol kinds (integers) to exclude. :param exclude_kinds: (optional) list of LSP symbol kinds (integers) to exclude.
:param substring_matching: If True, use substring matching for the last element of the pattern, such that :param substring_matching: If True, use substring matching for the last segment of `name_path_pattern`
"Foo/get" would match "Foo/getValue" and "Foo/getData". (i.e. the name of the symbol, e.g. "foo" in "Class/foo" or "my_method" in "my_method").
:param max_matches: maximum number of permitted matches. If exceeded, a shortened result is returned :param max_matches: Maximum number of permitted matches. If exceeded, a shortened result is returned
which allows refining the search. -1 (default) means no limit. Set to 1 if you search for a single symbol. which allows refining the search. -1 (default) means no limit. Set to 1 to search for a unique symbol.
:param max_answer_chars: max result length; -1 for default :param max_answer_chars: max result length; -1 for default
:return: symbols (with locations) matching the name. :return: symbols (with locations) matching the name.
""" """
# Note: file system sync not required; the symbol finder opens all relevant source files explicitly in the case of changes return (
self._api()
if include_body: .find_symbol(
depth = 0 # ignore user-specified depth if include_body is True name_path_pattern,
assert max_matches != 0, "max_matches must be > 0 or equal to -1."
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
symbol_retriever = self.create_language_server_symbol_retriever()
symbols = symbol_retriever.find(
name_path_pattern,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
substring_matching=substring_matching,
within_relative_path=relative_path,
)
n_matches = len(symbols)
def create_short_result_relative_path_to_name_paths() -> str:
relative_path_to_name_paths: defaultdict[str, list[str]] = defaultdict(list)
for s in symbols:
relative_path_to_name_paths[s.location.relative_path or "unknown"].append(s.get_name_path())
return f"Shortened result:\n{self._to_json(relative_path_to_name_paths)}"
if 0 < max_matches < n_matches:
return f"Matched {n_matches}>{max_matches=} symbols.\n" + create_short_result_relative_path_to_name_paths()
symbol_dicts = [
s.to_dict(
kind=True,
name_path=True,
name=False,
relative_path=True,
body_location=True,
depth=depth, depth=depth,
body=include_body, relative_path=relative_path,
children_name=True, include_body=include_body,
children_name_path=False, include_info=include_info,
include_kinds=include_kinds,
exclude_kinds=exclude_kinds,
substring_matching=substring_matching,
max_matches=max_matches,
max_answer_chars=max_answer_chars,
) )
for s in symbols .represent()
] )
if not include_body and include_info:
info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols)
for s, s_dict in zip(symbols, symbol_dicts, strict=True):
if symbol_info := info_by_symbol.get(s):
# In python 3.15 we could specify extra_items=True in the TypedDict definition,
# https://peps.python.org/pep-0728/
# If we ever upgrade to 3.15, we can remove the type: ignore[typeddict-unknown-key]
s_dict["info"] = symbol_info
grouped_symbol_dicts = self.symbol_dict_grouper.group(symbol_dicts)
result = self._to_json(grouped_symbol_dicts)
return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_short_result_relative_path_to_name_paths])
@classmethod @classmethod
def get_param_aliases(cls) -> dict[str, str]: def get_param_aliases(cls) -> dict[str, str]:
return {"name_path": "name_path_pattern"} return {"name_path": "name_path_pattern"}
class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, LspApiMixin):
""" """
Finds symbols that reference the given symbol using the language server backend Finds symbols that reference the given symbol
""" """
symbol_dict_grouper = LanguageServerSymbolDictGrouper(["relative_path", "kind"], ["kind"], collapse_singleton=True) @property
def symbol_dict_grouper(self) -> SymbolDictGrouper:
from serena.repl.api.lsp_api import LspApi
return LspApi.references_grouper_
# noinspection PyDefaultArgument
def apply( def apply(
self, self,
name_path: str, name_path: str,
@@ -277,75 +185,20 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
:param max_answer_chars: max result length; -1 for default :param max_answer_chars: max result length; -1 for default
:return: a list of JSON objects with the symbols referencing the requested symbol :return: a list of JSON objects with the symbols referencing the requested symbol
""" """
# file system sync needed for case where symbol finder does not perform a global search, updating everything return (
if relative_path: self._api()
self.project.ls_sync_file_system_changes() .find_referencing_symbols(
name_path, relative_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, max_answer_chars=max_answer_chars
include_body = False # It is probably never a good idea to include the body of the referencing symbols )
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None .represent()
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
symbol_retriever = self.create_language_server_symbol_retriever()
references_in_symbols = symbol_retriever.find_referencing_symbols(
name_path,
relative_file_path=relative_path,
include_body=include_body,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
) )
reference_dicts = []
for ref in references_in_symbols:
ref_dict_orig = ref.symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)
ref_dict = dict(ref_dict_orig)
if not include_body:
ref_relative_path = ref.symbol.location.relative_path
assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug."
content_around_ref = self.project.retrieve_content_around_line(
relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1
)
ref_dict["content_around_reference"] = content_around_ref.to_display_string()
reference_dicts.append(ref_dict)
# capture lightweight reference data before grouping class FindImplementationsTool(Tool, ToolMarkerSymbolicRead, LspApiMixin):
ref_summaries = []
for ref, d in zip(references_in_symbols, reference_dicts, strict=True):
ref_summaries.append(
{
"name_path": d.get("name_path"),
"kind": d.get("kind"),
"relative_path": d.get("relative_path"),
"reference_line": ref.line,
}
)
result = self.symbol_dict_grouper.group(reference_dicts)
# shortened result closures, from least to most aggressive shortening
def make_refs_without_context() -> str:
"""References with name_path and reference line, without surrounding code lines"""
grouped = self.symbol_dict_grouper.group(copy.deepcopy(ref_summaries))
return f"References without surrounding lines:\n{self._to_json(grouped)}"
def make_per_file_counts() -> str:
counts = Counter(str(r["relative_path"]) for r in ref_summaries)
return f"Reference counts per file:\n{self._to_json(counts)}"
def make_summary() -> str:
return f"Found {len(ref_summaries)} references."
shortened_results = [make_refs_without_context, make_per_file_counts, make_summary]
result_json = self._to_json(result)
return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results)
class FindImplementationsTool(Tool, ToolMarkerSymbolicRead):
""" """
Finds symbols that implement the given symbol using the language server backend. Finds the implementations of a symbol
""" """
# noinspection PyDefaultArgument
def apply( def apply(
self, self,
name_path: str, name_path: str,
@@ -368,36 +221,21 @@ class FindImplementationsTool(Tool, ToolMarkerSymbolicRead):
:param max_answer_chars: max result length; -1 for default :param max_answer_chars: max result length; -1 for default
:return: a list of JSON objects with the symbols implementing the requested symbol :return: a list of JSON objects with the symbols implementing the requested symbol
""" """
self.project.ls_sync_file_system_changes() return (
self._api()
include_body = False .find_implementations(
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None name_path,
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None relative_path,
symbol_retriever = self.create_language_server_symbol_retriever() include_info=include_info,
include_kinds=include_kinds,
implementing_symbols = symbol_retriever.find_implementing_symbols( exclude_kinds=exclude_kinds,
name_path, max_answer_chars=max_answer_chars,
relative_file_path=relative_path, )
include_body=include_body, .represent()
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
) )
symbol_dicts = [
dict(s.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) for s in implementing_symbols
]
if include_info:
info_by_symbol = symbol_retriever.request_info_for_symbol_batch(implementing_symbols)
for s, s_dict in zip(implementing_symbols, symbol_dicts, strict=True):
if symbol_info := info_by_symbol.get(s):
s_dict["info"] = symbol_info
s_dict.pop("name", None) # name is included in the info
result = self._to_json(symbol_dicts) class FindDeclarationTool(Tool, ToolMarkerSymbolicRead, LspApiMixin):
return self._limit_length(result, max_answer_chars)
class FindDeclarationTool(Tool, ToolMarkerSymbolicRead):
""" """
Finds the declaration/definition of a symbol Finds the declaration/definition of a symbol
""" """
@@ -423,70 +261,26 @@ class FindDeclarationTool(Tool, ToolMarkerSymbolicRead):
:param include_body: whether to include the symbol's body in the result. Default False. :param include_body: whether to include the symbol's body in the result. Default False.
:param include_info: whether to include additional info (hover-like). Default False. :param include_info: whether to include additional info (hover-like). Default False.
""" """
self.project.ls_sync_file_system_changes()
symbol_retriever = self.create_language_server_symbol_retriever()
relative_path = self._sanitize_input_param(relative_path) relative_path = self._sanitize_input_param(relative_path)
regex = self._sanitize_input_param(regex) regex = self._sanitize_input_param(regex)
return (
# find relevant location for lookup self._api()
editor = self.create_code_editor() .find_declaration(
if not containing_symbol_name_path: relative_path,
content = editor.read_file(relative_path) regex,
coords = find_text_coordinates(content, regex, require_unique=True) containing_symbol_name_path=containing_symbol_name_path,
assert coords is not None include_body=include_body,
else: include_info=include_info,
symbol = symbol_retriever.find_unique(name_path_pattern=containing_symbol_name_path, within_relative_path=relative_path)
body_line_numers = symbol.get_body_line_numbers_or_raise()
content = editor.read_file(relative_path, lines=body_line_numers)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
coords.line += body_line_numers[0]
# retrieve declaration
defining_symbol = symbol_retriever.find_declaration(
relative_file_path=relative_path,
line=coords.line,
column=coords.col,
include_body=include_body,
)
if defining_symbol is None:
raise ValueError(
f"No symbol declaration found at the location of the regex match. Location: {relative_path}:{coords.line}:{coords.col}."
) )
.represent()
# create output
symbol_dict = self._defining_symbol_to_result_dict(
symbol_retriever,
defining_symbol,
include_body,
include_info,
) )
result = self._to_json(symbol_dict)
return result
@staticmethod
def _defining_symbol_to_result_dict(
symbol_retriever: Any,
defining_symbol: LanguageServerSymbol,
include_body: bool,
include_info: bool,
) -> dict[str, Any]:
symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True))
if not include_body and include_info:
if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol):
symbol_dict["info"] = symbol_info
symbol_dict.pop("name", None)
return symbol_dict
class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead, LspApiMixin):
""" """
Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. Gets diagnostics for a file, grouped by symbol.
""" """
FILE_LEVEL_DIAGNOSTIC_BUCKET = "<file>"
def apply( def apply(
self, self,
relative_path: str, relative_path: str,
@@ -507,34 +301,16 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead):
:param max_answer_chars: max result length; -1 for default :param max_answer_chars: max result length; -1 for default
:return: grouped diagnostics for the requested file. :return: grouped diagnostics for the requested file.
""" """
self.project.ls_sync_file_system_changes() return (
self._api()
symbol_retriever = self.create_language_server_symbol_retriever() .get_diagnostics_for_file(
diagnostics = symbol_retriever.get_file_diagnostics( relative_path, start_line=start_line, end_line=end_line, min_severity=min_severity, max_answer_chars=max_answer_chars
relative_file_path=relative_path, )
start_line=start_line, .represent()
end_line=end_line,
min_severity=min_severity,
) )
grouped_diagnostics = GroupedDiagnostics()
for diagnostic in diagnostics:
diag_range = diagnostic["range"]["start"]
name_path = self.FILE_LEVEL_DIAGNOSTIC_BUCKET
owner_symbol = symbol_retriever.find_diagnostic_owner_symbol(
relative_file_path=relative_path,
line=diag_range["line"],
column=diag_range["character"],
)
if owner_symbol is not None:
name_path = owner_symbol.get_name_path()
grouped_diagnostics.add(relative_path, name_path, diagnostic)
result = self._to_json(grouped_diagnostics.get_dict()) class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional, LspApiMixin):
return self._limit_length(result, max_answer_chars)
class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
""" """
Gets diagnostics for a symbol and, optionally, for symbols that reference it. Gets diagnostics for a symbol and, optionally, for symbols that reference it.
""" """
@@ -560,30 +336,20 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption
:param max_answer_chars: max result length; -1 for default :param max_answer_chars: max result length; -1 for default
:return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols. :return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols.
""" """
self.project.ls_sync_file_system_changes() return (
self._api()
symbol_retriever = self.create_language_server_symbol_retriever() .get_diagnostics_for_symbol(
diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( name_path,
name_path=name_path, reference_file=reference_file,
reference_file=reference_file or None, check_symbol_references=check_symbol_references,
check_symbol_references=check_symbol_references, min_severity=min_severity,
min_severity=min_severity, max_answer_chars=max_answer_chars,
)
.represent()
) )
grouped_diagnostics = GroupedDiagnostics()
for symbol, diagnostics in diagnostics_by_symbol.items():
relative_path = symbol.relative_path
if relative_path is None:
continue
symbol_name_path = symbol.get_name_path()
for diagnostic in diagnostics:
grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic)
result = self._to_json(grouped_diagnostics.get_dict()) class ReplaceSymbolBodyTool(EditingToolWithDiagnostics, EditApiMixin):
return self._limit_length(result, max_answer_chars)
class ReplaceSymbolBodyTool(EditingToolWithDiagnostics):
""" """
Replaces the full definition of a symbol using the language server backend. Replaces the full definition of a symbol using the language server backend.
""" """
@@ -606,17 +372,12 @@ class ReplaceSymbolBodyTool(EditingToolWithDiagnostics):
in the programming language, including e.g. the signature line for functions. in the programming language, including e.g. the signature line for functions.
Depending on the language, it may or may not include a preceding docstring or other preceding annotations. Depending on the language, it may or may not include a preceding docstring or other preceding annotations.
""" """
with self.DiagnosticsContext(self, relative_path) as diagnostics_context: with self.diagnostics_context(relative_path) as diagnostics_context:
code_editor = self.create_code_editor() result = self._api().replace_symbol_body(name_path, relative_path, body)
code_editor.replace_body( return diagnostics_context.format_result(result)
name_path,
relative_file_path=relative_path,
body=body,
)
return diagnostics_context.format_result(SUCCESS_RESULT)
class InsertAfterSymbolTool(EditingToolWithDiagnostics): class InsertAfterSymbolTool(EditingToolWithDiagnostics, EditApiMixin):
""" """
Inserts content after the end of the definition of a given symbol. Inserts content after the end of the definition of a given symbol.
""" """
@@ -636,13 +397,12 @@ class InsertAfterSymbolTool(EditingToolWithDiagnostics):
:param body: the body/content to be inserted. The inserted code shall begin with the next line after :param body: the body/content to be inserted. The inserted code shall begin with the next line after
the symbol. the symbol.
""" """
with self.DiagnosticsContext(self, relative_path) as diagnostics_context: with self.diagnostics_context(relative_path) as diagnostics_context:
code_editor = self.create_code_editor() result = self._api().insert_after_symbol(name_path, relative_path, body)
code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) return diagnostics_context.format_result(result)
return diagnostics_context.format_result(SUCCESS_RESULT)
class InsertBeforeSymbolTool(EditingToolWithDiagnostics): class InsertBeforeSymbolTool(EditingToolWithDiagnostics, EditApiMixin):
""" """
Inserts content before the beginning of the definition of a given symbol. Inserts content before the beginning of the definition of a given symbol.
""" """
@@ -662,13 +422,12 @@ class InsertBeforeSymbolTool(EditingToolWithDiagnostics):
:param relative_path: the relative path to the file containing the symbol :param relative_path: the relative path to the file containing the symbol
:param body: the body/content to be inserted before the line in which the referenced symbol is defined :param body: the body/content to be inserted before the line in which the referenced symbol is defined
""" """
with self.DiagnosticsContext(self, relative_path) as diagnostics_context: with self.diagnostics_context(relative_path) as diagnostics_context:
code_editor = self.create_code_editor() result = self._api().insert_before_symbol(name_path, relative_path, body)
code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) return diagnostics_context.format_result(result)
return diagnostics_context.format_result(SUCCESS_RESULT)
class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit, LspApiMixin):
""" """
Renames a symbol throughout the codebase using language server refactoring capabilities. Renames a symbol throughout the codebase using language server refactoring capabilities.
For JB, we use a separate tool. For JB, we use a separate tool.
@@ -690,13 +449,10 @@ class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit):
:param new_name: the new name for the symbol :param new_name: the new name for the symbol
:return: result summary indicating success or failure :return: result summary indicating success or failure
""" """
self.project.ls_sync_file_system_changes() return self._api().rename_symbol(name_path, relative_path, new_name)
code_editor = self.create_ls_code_editor()
status_message = code_editor.rename_symbol(name_path, relative_path=relative_path, new_name=new_name)
return status_message
class SafeDeleteSymbol(Tool, ToolMarkerSymbolicEdit): class SafeDeleteSymbol(Tool, ToolMarkerSymbolicEdit, LspApiMixin):
def apply( def apply(
self, self,
name_path_pattern: str, name_path_pattern: str,
@@ -709,31 +465,4 @@ class SafeDeleteSymbol(Tool, ToolMarkerSymbolicEdit):
:param name_path_pattern: name path of the symbol to delete :param name_path_pattern: name path of the symbol to delete
:param relative_path: the relative path to the file containing the symbol to delete :param relative_path: the relative path to the file containing the symbol to delete
""" """
self.project.ls_sync_file_system_changes() return self._api().safe_delete_symbol(name_path_pattern, relative_path)
ls_symbol_retriever = self.create_language_server_symbol_retriever()
symbol = ls_symbol_retriever.find_unique(name_path_pattern, substring_matching=False, within_relative_path=relative_path)
symbol_rel_path = symbol.relative_path
assert symbol_rel_path is not None, f"Symbol {name_path_pattern} has no relative path, this is likely a bug."
assert symbol_rel_path == relative_path, f"Symbol {name_path_pattern} is not in the expected relative path {relative_path}."
symbol_name_path = symbol.get_name_path()
symbol_line = symbol.line
symbol_col = symbol.column
assert symbol_line is not None and symbol_col is not None, (
f"Symbol {name_path_pattern} has no identifier position, this is likely a bug."
)
lang_server = ls_symbol_retriever.get_language_server(symbol_rel_path)
references_locations = lang_server.request_references(symbol_rel_path, symbol_line, symbol_col)
file_to_lines: dict[str, list[int]] = defaultdict(list)
if references_locations:
for ref_loc in references_locations:
ref_relative_path = ref_loc.get("relativePath")
if ref_relative_path is None:
continue
file_to_lines[ref_relative_path].append(ref_loc["range"]["start"]["line"])
if file_to_lines:
return f"Cannot delete, the symbol {symbol_name_path} is referenced in: {self._to_json(file_to_lines)}"
code_editor = self.create_ls_code_editor()
code_editor.delete_symbol(symbol_name_path, relative_file_path=symbol_rel_path)
return SUCCESS_RESULT
+36 -152
View File
@@ -5,33 +5,35 @@ import json
from abc import ABC from abc import ABC
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast
from types import TracebackType
from typing import TYPE_CHECKING, Any, Optional, Protocol, Self, TypeVar, cast
from mcp import Implementation from mcp import Implementation
from mcp.server.fastmcp import Context from mcp.server.mcpserver import Context
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
from sensai.util import logging from sensai.util import logging
from sensai.util.helper import mark_used
from sensai.util.string import dict_string from sensai.util.string import dict_string
from serena.config.serena_config import LanguageBackend from serena.code_editor import EditedFileContext
from serena.lsp.lsp_diagnostics import DiagnosticsContext
from serena.memories.memory_manager import MemoryManager from serena.memories.memory_manager import MemoryManager
from serena.project import Project from serena.project import Project
from serena.prompt_factory import PromptFactory from serena.prompt_factory import PromptFactory
from serena.repl.facade import SUCCESS_RESULT
from serena.util.class_decorators import singleton from serena.util.class_decorators import singleton
from serena.util.inspection import iter_subclasses from serena.util.inspection import iter_subclasses
from serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot from serena.util.text_utils import TextOutputUtils
from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_exceptions import SolidLSPException
if TYPE_CHECKING: if TYPE_CHECKING:
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor, LanguageServerCodeEditor from serena.code_editor import CodeEditor
from serena.symbol import LanguageServerSymbolRetriever from serena.symbol import LanguageServerSymbolRetriever
mark_used(SUCCESS_RESULT, EditedFileContext) # backward compatibility
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")
SUCCESS_RESULT = "OK"
class Component(ABC): class Component(ABC):
@@ -63,22 +65,7 @@ class Component(ABC):
return self.agent.get_active_project_or_raise() return self.agent.get_active_project_or_raise()
def create_code_editor(self) -> "CodeEditor": def create_code_editor(self) -> "CodeEditor":
from ..code_editor import JetBrainsCodeEditor return self.agent.get_language_backend().create_code_editor(self.project)
match self.agent.get_language_backend():
case LanguageBackend.LSP:
return self.create_ls_code_editor()
case LanguageBackend.JETBRAINS:
return JetBrainsCodeEditor(project=self.project)
case _:
raise ValueError
def create_ls_code_editor(self) -> "LanguageServerCodeEditor":
from ..code_editor import LanguageServerCodeEditor
if not self.agent.is_using_language_server():
raise Exception("Cannot create LanguageServerCodeEditor; agent is not in language server mode.")
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever())
class ToolMarker: class ToolMarker:
@@ -152,32 +139,12 @@ class Tool(Component):
# (which is use by the LLM, so a good description is important) # (which is use by the LLM, so a good description is important)
# and to validate the tool call arguments. # and to validate the tool call arguments.
SESSION_ID_PARAM_NAME = "session_id"
"""
parameter name to use in apply method for the client session ID.
This parameter will be ignored by the MCP interface but will be populated with the session ID of the current client session
when the tool is called, allowing tools to be session-aware if needed.
"""
_last_tool_call_client_str: str | None = None _last_tool_call_client_str: str | None = None
"""We can only get the client info from within a tool call. Each tool call will update this variable.""" """We can only get the client info from within a tool call. Each tool call will update this variable."""
def __init__(self, agent: "SerenaAgent"): def __init__(self, agent: "SerenaAgent"):
super().__init__(agent) super().__init__(agent)
@cached_property
def _is_session_aware(self) -> bool:
"""
:return: whether the tool is session-aware, i.e. whether the apply method expects a session_id (str) parameter.
"""
# check apply method for session_id arg
apply_fn = self.get_apply_fn()
sig = inspect.signature(apply_fn)
for param in sig.parameters.values():
if param.name == self.SESSION_ID_PARAM_NAME:
return True
return False
@staticmethod @staticmethod
def _sanitize_input_param(raw_param: str) -> str: def _sanitize_input_param(raw_param: str) -> str:
# some clients replace < and > with their escaped html versions, we need to counteract this # some clients replace < and > with their escaped html versions, we need to counteract this
@@ -266,9 +233,9 @@ class Tool(Component):
if apply_fn is None: if apply_fn is None:
raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?")
return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME], structured_output=structured_output) return func_metadata(apply_fn, skip_names=["self", "cls"], structured_output=structured_output)
def _log_tool_application(self, frame: Any, session_id: str) -> None: def _log_tool_application(self, frame: Any) -> None:
params = {} params = {}
ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"} ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"}
for param, value in frame.f_locals.items(): for param, value in frame.f_locals.items():
@@ -278,7 +245,14 @@ class Tool(Component):
params.update(value) params.update(value)
else: else:
params[param] = value params[param] = value
log.info(f"{self.get_name_from_cls()}: {dict_string(params)}; session_id: {session_id}") log.info(f"{self.get_name_from_cls()}: {dict_string(params)}")
def _resolve_max_answer_chars(self, max_answer_chars: int) -> int:
"""
:param max_answer_chars: the maximum number of answer characters as passed to the tool; -1 for the configured default
:return: the effective maximum
"""
return self.agent.serena_config.default_max_tool_answer_chars if max_answer_chars == -1 else max_answer_chars
def _limit_length( def _limit_length(
self, self,
@@ -294,26 +268,13 @@ class Tool(Component):
version of the result. They are tried in order until one fits within ``max_answer_chars``. version of the result. They are tried in order until one fits within ``max_answer_chars``.
:return: the result string, potentially replaced by a shortened version :return: the result string, potentially replaced by a shortened version
""" """
if max_answer_chars == -1: max_answer_chars = self._resolve_max_answer_chars(max_answer_chars)
max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars return TextOutputUtils.limit_length(
if max_answer_chars <= 0: result=result, max_answer_chars=max_answer_chars, shortened_result_factories=shortened_result_factories
raise ValueError(f"Must be positive or the default (-1), got: {max_answer_chars=}") )
if (n_chars := len(result)) > max_answer_chars:
too_long_msg = (
f"The answer is too long ({n_chars} characters). " + "You can adjust your query or raise the max_answer_chars parameter."
)
if shortened_result_factories is not None:
# try each shortening closure in order;
for make_shorter in shortened_result_factories:
shortened = make_shorter()
candidate = f"{too_long_msg}\n{shortened}"
if len(candidate) <= max_answer_chars:
return candidate
result = too_long_msg
return result
def is_active(self) -> bool: def is_active(self) -> bool:
return self.agent.tool_is_active(self.get_name()) return self.agent.get_active_tools().contains_tool_name(self.get_name())
def is_readonly(self) -> bool: def is_readonly(self) -> bool:
return not self.can_edit() return not self.can_edit()
@@ -339,10 +300,8 @@ class Tool(Component):
:param catch_exceptions: whether to catch exceptions and return their messages as strings, instead of raising a ToolCallError :param catch_exceptions: whether to catch exceptions and return their messages as strings, instead of raising a ToolCallError
""" """
# obtain session ID and client info # obtain session ID and client info
session_id = "global"
if mcp_ctx is not None: if mcp_ctx is not None:
try: try:
session_id = "%x" % id(mcp_ctx.session)
client_params = mcp_ctx.session.client_params client_params = mcp_ctx.session.client_params
if client_params is not None: if client_params is not None:
client_info = cast(Implementation, client_params.clientInfo) client_info = cast(Implementation, client_params.clientInfo)
@@ -363,7 +322,7 @@ class Tool(Component):
) )
if log_call: if log_call:
self._log_tool_application(inspect.currentframe(), session_id) self._log_tool_application(inspect.currentframe())
# check whether the tool requires an active project and language server # check whether the tool requires an active project and language server
if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): if not isinstance(self, ToolMarkerDoesNotRequireActiveProject):
@@ -375,8 +334,6 @@ class Tool(Component):
# construct apply kwargs, adding session_id if the tool is session-aware # construct apply kwargs, adding session_id if the tool is session-aware
apply_kwargs = dict(kwargs) apply_kwargs = dict(kwargs)
if self._is_session_aware:
apply_kwargs["session_id"] = session_id
# apply the actual tool # apply the actual tool
try: try:
@@ -444,7 +401,7 @@ class Tool(Component):
@staticmethod @staticmethod
def _to_json(x: Any) -> str: def _to_json(x: Any) -> str:
return json.dumps(x, ensure_ascii=False) return TextOutputUtils.to_json(x)
def _wrapped_tool_response(self, response: Any, message: str) -> str: def _wrapped_tool_response(self, response: Any, message: str) -> str:
""" """
@@ -478,89 +435,16 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit):
are then resolved in subsequent edits. are then resolved in subsequent edits.
""" """
DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]" def diagnostics_context(self, *edited_relative_paths: str) -> DiagnosticsContext:
class DiagnosticsContext:
def __init__(self, tool: "EditingToolWithDiagnostics", *edited_relative_paths: str) -> None:
self._tool = tool
self._is_diagnostics_enabled = tool.ENABLE_DIAGNOSTICS and tool.agent.is_using_language_server()
self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths]
self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None
self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None
if self._is_diagnostics_enabled:
self._symbol_retriever = tool.create_language_server_symbol_retriever()
self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, self._symbol_retriever)
def __enter__(self) -> Self:
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def format_result(
self,
base_result: str,
) -> str:
if not self._is_diagnostics_enabled:
return base_result
if self._before_edit_diagnostics_snapshot is None:
return base_result
assert self._symbol_retriever is not None
diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever)
grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict()
if not grouped_diagnostics:
return base_result
else:
result_dict = {
"result": base_result,
EditingToolWithDiagnostics.DIAGNOSTICS_KEY: grouped_diagnostics,
}
return self._tool._to_json(result_dict)
class EditedFileContext:
"""
Context manager for file editing.
Create the context, then use `set_updated_content` to set the new content, the original content
being provided in `original_content`.
When exiting the context without an exception, the updated content will be written back to the file.
"""
def __init__(self, relative_path: str, code_editor: "CodeEditor"):
self._relative_path = relative_path
self._code_editor = code_editor
self._edited_file: CodeEditor.EditedFile | None = None
self._edited_file_context: Any = None
def __enter__(self) -> Self:
self._edited_file_context = self._code_editor.edited_file_context(self._relative_path)
self._edited_file = self._edited_file_context.__enter__()
return self
def get_original_content(self) -> str:
""" """
:return: the original content of the file before any modifications. Creates a context for use with the `with` statement, which captures the diagnostics before the edit,
""" such that changes can be reported
assert self._edited_file is not None
return self._edited_file.get_contents()
def set_updated_content(self, content: str) -> None: :param edited_relative_paths: the relative paths of the files that are to be edited within the context
:return: a context which captures the diagnostics before the edit, such that changes can be reported
via `format_result`
""" """
Sets the updated content of the file, which will be written back to the file return DiagnosticsContext(self.agent, *edited_relative_paths, enable=self.ENABLE_DIAGNOSTICS)
when the context is exited without an exception.
:param content: the updated content of the file
"""
assert self._edited_file is not None
self._edited_file.set_contents(content)
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
assert self._edited_file_context is not None
self._edited_file_context.__exit__(exc_type, exc_value, traceback)
@dataclass(kw_only=True) @dataclass(kw_only=True)
+6 -13
View File
@@ -3,12 +3,11 @@ Tools supporting the general workflow of the agent
""" """
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import platform
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional, WriteMemoryTool from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional, WriteMemoryTool
from serena.tools.memory_tools import MemoryApiMixin
class OnboardingTool(Tool): class OnboardingTool(Tool, MemoryApiMixin):
""" """
Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
""" """
@@ -20,14 +19,10 @@ class OnboardingTool(Tool):
:return: instructions on how to create the onboarding information :return: instructions on how to create the onboarding information
""" """
write_memory_tool_available = self.agent.tool_is_exposed(WriteMemoryTool.get_name_from_cls()) write_memory_tool_available = self.agent.is_tool_function_available(WriteMemoryTool)
if not write_memory_tool_available: if not write_memory_tool_available:
return "Memory writing tool not activated, skipping onboarding." return "Memory writing tool not activated, skipping onboarding."
system = platform.system() return self._api().onboarding()
# seed the project-local memory-maintenance memory (or detect a global override) so
# the prompt can point the agent at the conventions before it writes anything
memory_maintenance_name = self.memory_manager.ensure_memory_maintenance_memory()
return self.prompt_factory.create_onboarding_prompt(system=system, memory_maintenance_name=memory_maintenance_name)
class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject):
@@ -36,15 +31,13 @@ class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject):
for clients that do not read the initial instructions when the MCP server is connected. for clients that do not read the initial instructions when the MCP server is connected.
""" """
# noinspection PyIncorrectDocstring def apply(self) -> str:
# (session_id is injected via apply_ex)
def apply(self, session_id: str) -> str:
""" """
Provides the 'Serena Instructions Manual', which contains essential information on how to use the Serena toolbox. Provides the 'Serena Instructions Manual', which contains essential information on how to use the Serena toolbox.
IMPORTANT: If you have not yet read the manual, call this tool immediately after you are given your task by the user, IMPORTANT: If you have not yet read the manual, call this tool immediately after you are given your task by the user,
as it will critically inform you! as it will critically inform you!
""" """
return self.agent.create_system_prompt(session_id=session_id) return self.agent.create_system_prompt()
class SerenaInfoTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): class SerenaInfoTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject):
+3 -32
View File
@@ -6,8 +6,6 @@ from abc import ABC, abstractmethod
from collections.abc import Iterator from collections.abc import Iterator
from typing import TYPE_CHECKING, Self from typing import TYPE_CHECKING, Self
from serena.jetbrains import jetbrains_types as jb
if TYPE_CHECKING: if TYPE_CHECKING:
from serena.project import Project from serena.project import Project
@@ -30,19 +28,15 @@ class FileProxy(ABC):
""" """
@staticmethod @staticmethod
def is_external_path(relative_path: str) -> bool: def is_external_path(relative_path: str, project: "Project") -> bool:
""" """
:return: whether the given relative path is an encoded external path (not a local project file) :return: whether the given relative path is an encoded external path (not a local project file)
""" """
# This is intended to be extended once we also support external paths in other backends return project.language_backend.is_external_path(relative_path)
return jb.is_external_path(relative_path)
@classmethod @classmethod
def from_project_relative_path(cls, project: "Project", relative_path: str) -> "FileProxy": def from_project_relative_path(cls, project: "Project", relative_path: str) -> "FileProxy":
if cls.is_external_path(relative_path): return project.language_backend.create_file_proxy(relative_path, project)
if project.language_backend.is_jetbrains():
return JetBrainsFileProxy(relative_path, project)
return LocalProjectFileProxy(relative_path, project)
class LocalProjectFileProxy(FileProxy): class LocalProjectFileProxy(FileProxy):
@@ -62,29 +56,6 @@ class LocalProjectFileProxy(FileProxy):
return True return True
class JetBrainsFileProxy(FileProxy):
"""
Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path,
which may be an external path (e.g., "<ext:FileUtil.class|472e0a13>")
"""
def __init__(self, relative_path: str, project: "Project"):
self._relative_path = relative_path
self._project = project
def get_contents(self) -> str:
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
client = JetBrainsPluginClient.from_project(self._project)
return client.read_file(self._relative_path)
def get_relative_path(self) -> str:
return self._relative_path
def is_glob_supported(self):
return False
class FileCollection: class FileCollection:
def __init__(self, file_proxies: list[FileProxy]): def __init__(self, file_proxies: list[FileProxy]):
self._file_proxies = file_proxies self._file_proxies = file_proxies
+12 -3
View File
@@ -31,6 +31,11 @@ def write_file_atomic(path: str, content: str, *, encoding: str, newline: str |
:param encoding: the encoding to use for the write :param encoding: the encoding to use for the write
:param newline: passed through to the underlying ``open()`` call to control newline translation :param newline: passed through to the underlying ``open()`` call to control newline translation
""" """
# ``open(path, "w")`` follows symlinks and writes through to the target, whereas replacing the
# link path itself would swap the link out for a regular file and leave its target holding the
# old content. Resolving first keeps this a drop-in replacement, and puts the temporary file in
# the destination's real directory, which is where it has to be for the rename to be atomic.
path = os.path.realpath(path)
target_dir = os.path.dirname(path) or "." target_dir = os.path.dirname(path) or "."
try: try:
existing_mode: int | None = stat.S_IMODE(os.stat(path).st_mode) existing_mode: int | None = stat.S_IMODE(os.stat(path).st_mode)
@@ -435,7 +440,7 @@ class GitignoreParser:
self._load_gitignore_files() self._load_gitignore_files()
def match_path(relative_path: str, path_spec: PathSpec, root_path: str = "") -> bool: def match_path(relative_path: str, path_spec: PathSpec, root_path: str = "", is_dir: bool | None = None) -> bool:
""" """
Match a relative path against a given pathspec. Just pathspec.match_file() is not enough, Match a relative path against a given pathspec. Just pathspec.match_file() is not enough,
we need to do some massaging to fix issues with pathspec matching. we need to do some massaging to fix issues with pathspec matching.
@@ -443,6 +448,8 @@ def match_path(relative_path: str, path_spec: PathSpec, root_path: str = "") ->
:param relative_path: relative path to match against the pathspec :param relative_path: relative path to match against the pathspec
:param path_spec: the pathspec to match against :param path_spec: the pathspec to match against
:param root_path: the root path from which the relative path is derived :param root_path: the root path from which the relative path is derived
:param is_dir: whether the path is a directory, where the caller already knows; passing it avoids
an `os.path.isdir` call. `None` determines it from the filesystem.
:return: :return:
""" """
if str(relative_path) in {"", "."}: if str(relative_path) in {"", "."}:
@@ -460,7 +467,9 @@ def match_path(relative_path: str, path_spec: PathSpec, root_path: str = "") ->
# pathspec can't handle the matching of directories if they don't end with a slash! # pathspec can't handle the matching of directories if they don't end with a slash!
# see https://github.com/cpburnz/python-pathspec/issues/89 # see https://github.com/cpburnz/python-pathspec/issues/89
abs_path = os.path.abspath(os.path.join(root_path, relative_path)) if is_dir is None:
if os.path.isdir(abs_path) and not normalized_path.endswith("/"): abs_path = os.path.abspath(os.path.join(root_path, relative_path))
is_dir = os.path.isdir(abs_path)
if is_dir and not normalized_path.endswith("/"):
normalized_path = normalized_path + "/" normalized_path = normalized_path + "/"
return path_spec.match_file(normalized_path) return path_spec.match_file(normalized_path)
+18 -10
View File
@@ -6,7 +6,7 @@ from collections.abc import Callable, Iterator
from typing import TypeVar from typing import TypeVar
from serena.util.file_system import find_all_non_ignored_files from serena.util.file_system import find_all_non_ignored_files
from solidlsp.ls_config import LanguageServerId from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike
T = TypeVar("T") T = TypeVar("T")
@@ -16,22 +16,30 @@ log = logging.getLogger(__name__)
def iter_subclasses( def iter_subclasses(
cls: type[T], recursive: bool = True, inclusion_predicate: Callable[[type[T]], bool] = lambda t: True cls: type[T], recursive: bool = True, inclusion_predicate: Callable[[type[T]], bool] = lambda t: True
) -> Iterator[type[T]]: ) -> Iterator[type[T]]:
"""Iterate over all subclasses of a class. """Iterate over all subclasses of a class, yielding each subclass once (even if it is reachable via multiple base classes).
:param cls: The class whose subclasses to iterate over. :param cls: The class whose subclasses to iterate over.
:param recursive: If True, also iterate over all subclasses of all subclasses. :param recursive: If True, also iterate over all subclasses of all subclasses.
:param inclusion_predicate: a predicate function to decide whether to include a subclass in the result :param inclusion_predicate: a predicate function to decide whether to include a subclass in the result
""" """
for subclass in cls.__subclasses__(): seen: set[type] = set()
if inclusion_predicate(subclass):
yield subclass def iterate(c: type[T]) -> Iterator[type[T]]:
if recursive: for subclass in c.__subclasses__():
yield from iter_subclasses(subclass, recursive, inclusion_predicate) if subclass in seen:
continue
seen.add(subclass)
if inclusion_predicate(subclass):
yield subclass
if recursive:
yield from iterate(subclass)
yield from iterate(cls)
def compute_language_server_support_composition( def compute_language_server_support_composition(
repo_path: str, ls_ids: list[LanguageServerId] | None = None repo_path: str, ls_ids: list[LanguageServerIdLike] | None = None
) -> dict[LanguageServerId, float]: ) -> dict[LanguageServerIdLike, float]:
""" """
Determine the composition of a repository in terms of the language servers that can be used to analyze it. Determine the composition of a repository in terms of the language servers that can be used to analyze it.
@@ -56,7 +64,7 @@ def compute_language_server_support_composition(
matchers = {lang: lang.get_source_fn_matcher() for lang in ls_ids} matchers = {lang: lang.get_source_fn_matcher() for lang in ls_ids}
# count files per language in a single pass over the files # count files per language in a single pass over the files
ls_file_counts: dict[LanguageServerId, int] = {} ls_file_counts: dict[LanguageServerIdLike, int] = {}
recognised_files = 0 recognised_files = 0
for file_path in all_files: for file_path in all_files:
# Use just the filename for matching, not the full path # Use just the filename for matching, not the full path
+317 -12
View File
@@ -1,12 +1,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import hashlib import hashlib
import json
import logging import logging
import re import re
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from typing import Any, Literal, Self from typing import TYPE_CHECKING, Any, Literal, Self
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from joblib import Parallel, delayed from joblib import Parallel, delayed
@@ -15,6 +16,10 @@ from sensai.util.string import ToStringMixin
from serena.util.file_proxy import FileCollection, FileProxy from serena.util.file_proxy import FileCollection, FileProxy
from solidlsp.ls_utils import TextCoordinateProvider, TextCoordinates, TextUtils from solidlsp.ls_utils import TextCoordinateProvider, TextCoordinates, TextUtils
if TYPE_CHECKING:
from serena.code_editor import CodeEditor
from serena.project import Project
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -406,13 +411,18 @@ class ContentReplacer:
self.regex_multiline = regex_multiline self.regex_multiline = regex_multiline
@staticmethod @staticmethod
def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]: def _create_replacement_function(
regex_pattern: str, repl_template: str, regex_flags: int, expand_backrefs: bool
) -> Callable[[re.Match], str]:
""" """
Creates a replacement function that validates for ambiguity and handles backreferences. Creates a replacement function that validates for ambiguity and handles backreferences.
:param regex_pattern: The regex pattern being used for matching :param regex_pattern: The regex pattern being used for matching
:param repl_template: The replacement template with $!1, $!2, etc. for backreferences :param repl_template: The replacement template; in regex mode, it may contain $!1, $!2, etc. for
backreferences; in literal mode, it is used verbatim
:param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE) :param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE)
:param expand_backrefs: Whether $!N backreferences are expanded in the template; false in literal mode,
mirroring the mode gate in MultiFileContentReplacer.find_occurrences
:return: A function suitable for use with re.sub() or re.subn() :return: A function suitable for use with re.sub() or re.subn()
""" """
@@ -434,11 +444,19 @@ class ContentReplacer:
"e.g. by matching specific context after the match, or try using the literal mode." "e.g. by matching specific context after the match, or try using the literal mode."
) )
# Handle backreferences: replace $!1, $!2, etc. with actual matched groups # in literal mode, the template is the final replacement; $!N sequences need no escaping
if not expand_backrefs:
return repl_template
# Handle backreferences: replace $!1, $!2, etc. with actual matched groups; groups that
# exist but did not participate in the match expand to the empty string
def expand_backreference(m: re.Match) -> str: def expand_backreference(m: re.Match) -> str:
group_num = int(m.group(1)) group_num = int(m.group(1))
group_value = match.group(group_num) try:
return group_value if group_value is not None else m.group(0) group_value = match.group(group_num)
except IndexError as e:
raise ValueError(f"Backreference $!{group_num} refers to a group that does not exist in the search expression") from e
return group_value if group_value is not None else ""
result = re.sub(r"\$!(\d+)", expand_backreference, repl_template) result = re.sub(r"\$!(\d+)", expand_backreference, repl_template)
return result return result
@@ -458,8 +476,8 @@ class ContentReplacer:
:param content: the content in which to perform the replacement :param content: the content in which to perform the replacement
:param needle: the search expression, which is either a literal string or a regular expression, depending on the mode :param needle: the search expression, which is either a literal string or a regular expression, depending on the mode
:param repl: the replacement string, which, in regex mode, may contain backreferences in the form of $!1, $!2, etc. to :param repl: the replacement string; in regex mode, it may contain backreferences in the form of $!1, $!2, etc.
refer to matched groups in the search expression to refer to matched groups in the search expression; in literal mode, it is used verbatim
:return: the updated content after performing the replacement :return: the updated content after performing the replacement
""" """
if self.mode == "literal": if self.mode == "literal":
@@ -471,8 +489,8 @@ class ContentReplacer:
regex_flags = (re.MULTILINE | re.DOTALL) if self.regex_multiline else 0 regex_flags = (re.MULTILINE | re.DOTALL) if self.regex_multiline else 0
# create replacement function with validation and backreference handling # create replacement function with ambiguity validation and, in regex mode, backreference handling
repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags) repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags, expand_backrefs=self.mode == "regex")
# perform replacement # perform replacement
updated_content, n = re.subn(regex, repl_fn, content, flags=regex_flags) updated_content, n = re.subn(regex, repl_fn, content, flags=regex_flags)
@@ -548,8 +566,12 @@ class MultiFileContentReplacer:
"""Expands $!1, $!2, ... in the replacement template (same syntax as :class:`ContentReplacer`).""" """Expands $!1, $!2, ... in the replacement template (same syntax as :class:`ContentReplacer`)."""
def expand(m: re.Match) -> str: def expand(m: re.Match) -> str:
group_value = match.group(int(m.group(1))) group_num = int(m.group(1))
return group_value if group_value is not None else m.group(0) try:
group_value = match.group(group_num)
except IndexError as e:
raise ValueError(f"Backreference $!{group_num} refers to a group that does not exist in the search expression") from e
return group_value if group_value is not None else ""
return re.sub(r"\$!(\d+)", expand, repl_template) return re.sub(r"\$!(\d+)", expand, repl_template)
@@ -643,6 +665,253 @@ class MultiFileContentReplacer:
return "\n".join(diff_lines) return "\n".join(diff_lines)
class ReplacementRejectedError(ValueError):
"""
Raised when a replacement is not applied because a safety check failed; no changes have been made.
"""
def __init__(self, message: str, show_prospective_changes: bool) -> None:
"""
:param message: the reason for the rejection
:param show_prospective_changes: whether the listing of the prospective changes should be presented along
with the message (the message refers to it)
"""
super().__init__(message)
self.show_prospective_changes = show_prospective_changes
@dataclass
class MultiFileReplacementResult:
"""
The result of an applied replacement.
"""
num_occurrences_by_file: dict[str, int]
"""the number of replaced occurrences per file (relative path), in application order"""
@property
def num_occurrences(self) -> int:
return sum(self.num_occurrences_by_file.values())
def to_display_string(self) -> str:
per_file = "\n".join(f" {path}: {n}" for path, n in self.num_occurrences_by_file.items())
return f"Replaced {self.num_occurrences} occurrence(s) in {len(self.num_occurrences_by_file)} file(s):\n{per_file}"
class MultiFileReplacement:
"""
A prospective replacement of a pattern across the files of a project within a given scope: holds the
occurrences found, supports selecting occurrences by their ids, guards blind application against
unintended replacements, renders a preview listing and applies the replacement via a code editor.
"""
def __init__(
self,
project: "Project",
needle: str,
repl: str,
mode: Literal["literal", "regex"],
relative_path: str = "",
paths_include_glob: str = "",
paths_exclude_glob: str = "",
) -> None:
"""
:param project: the project whose files are to be searched
:param needle: the string (mode "literal") or regular expression (mode "regex") to search for
:param repl: the replacement string (may contain $!N backreferences in regex mode)
:param mode: how `needle` is to be interpreted
:param relative_path: only consider this file or directory (default: the whole project)
:param paths_include_glob: optional glob restricting which files are considered
:param paths_exclude_glob: optional glob of files to exclude; takes precedence over the include glob
"""
self._replacer = MultiFileContentReplacer(mode=mode)
self._needle = needle
self._repl = repl
files = self._collect_files(project, relative_path.strip(), paths_include_glob.strip(), paths_exclude_glob.strip())
self._contents = dict(files)
self.occurrences: list[ReplacementOccurrence] = self._replacer.find_occurrences(files, needle, repl)
@staticmethod
def _collect_files(project: "Project", relative_path: str, paths_include_glob: str, paths_exclude_glob: str) -> list[tuple[str, str]]:
"""
:return: the (relative_path, content) pairs of the readable, non-ignored files in scope, in sorted path order
"""
if relative_path:
project.validate_relative_path(relative_path, require_not_ignored=True)
file_collection = project.create_file_collection(relative_path, code_files_only=False, skip_ignored_files=True).filter_glob(
paths_include_glob or None, paths_exclude_glob or None
)
files: list[tuple[str, str]] = []
for file_proxy in sorted(file_collection, key=lambda f: f.get_relative_path()):
try:
files.append((file_proxy.get_relative_path(), file_proxy.get_contents()))
except Exception:
continue # skip unreadable (e.g. binary) files
return files
@property
def affected_files(self) -> list[str]:
"""
:return: the relative paths of the files containing occurrences, sorted
"""
return sorted({o.relative_path for o in self.occurrences})
def select(self, occurrence_ids: list[str]) -> list[ReplacementOccurrence]:
"""
Resolves the given occurrence ids (as obtained from a previous listing).
:param occurrence_ids: the ids of the occurrences to select
:return: the selected occurrences
:raises ReplacementRejectedError: if the selection is empty or any id cannot be resolved
"""
occurrences_by_id = {o.occurrence_id: o for o in self.occurrences}
indices_by_path: dict[str, set[int]] = {}
for o in self.occurrences:
indices_by_path.setdefault(o.relative_path, set()).add(o.index_in_file)
# resolve each id, diagnosing failures
selected: dict[str, ReplacementOccurrence] = {}
problems: list[str] = []
for oid in occurrence_ids:
occurrence = occurrences_by_id.get(oid)
if occurrence is not None:
selected[oid] = occurrence
continue
id_match = MultiFileContentReplacer.OCCURRENCE_ID_REGEX.match(oid)
if id_match is None:
problems.append(f"{oid}: malformed id (expected '<path>:<index>@<digest>' as returned by a dry run)")
elif id_match.group("path") not in indices_by_path:
problems.append(f"{oid}: the pattern currently has no matches in this file")
elif int(id_match.group("index")) not in indices_by_path[id_match.group("path")]:
problems.append(f"{oid}: the file now has fewer matches than at dry-run time (content changed)")
else:
problems.append(f"{oid}: the matched text changed since the dry run (content changed)")
if problems:
problem_lines = "\n".join(f" {p}" for p in problems)
raise ReplacementRejectedError(
f"{len(problems)} of the given occurrence_ids could not be resolved - NO changes were applied:\n"
f"{problem_lines}\n"
"Re-run with dry_run=True to obtain current occurrence ids.",
show_prospective_changes=False,
)
if not selected:
raise ReplacementRejectedError(
"occurrence_ids is empty - pass at least one id from a dry run, or omit the parameter to replace all.",
show_prospective_changes=False,
)
return list(selected.values())
def select_all_guarded(self, expected_count: int = -1) -> list[ReplacementOccurrence]:
"""
Selects all occurrences for a blind application (without explicit selection), applying safety checks.
:param expected_count: the number of occurrences expected; -1 disables the check
:return: all occurrences
:raises ReplacementRejectedError: if there are no occurrences, the count differs from the expectation
or any occurrence is ambiguous
"""
if not self.occurrences:
raise ReplacementRejectedError(
"No occurrences of the pattern were found - NO changes were applied. "
"Check the mode (a literal needle containing regex metacharacters must use mode 'literal'; "
"wildcards require mode 'regex') and the path/glob restrictions, "
"or locate the content with search_for_pattern first.",
show_prospective_changes=False,
)
if expected_count >= 0 and len(self.occurrences) != expected_count:
raise ReplacementRejectedError(
f"expected_count={expected_count}, but the pattern matches {len(self.occurrences)} occurrence(s) - "
"NO changes were applied. Review the prospective changes below; re-issue with the corrected "
"expectation, a refined pattern, or occurrence_ids selecting the intended subset.",
show_prospective_changes=True,
)
num_ambiguous = sum(1 for o in self.occurrences if o.is_ambiguous)
if num_ambiguous:
raise ReplacementRejectedError(
f"{num_ambiguous} occurrence(s) are ambiguous (the pattern matches again inside the matched text, "
"indicating possible over-matching) - NO changes were applied. Review the prospective changes below "
"and either refine the pattern or explicitly select occurrences via occurrence_ids.",
show_prospective_changes=True,
)
return list(self.occurrences)
def render_listing(self, max_answer_chars: int, dry_run: bool) -> str:
"""
Renders the prospective changes as a list of minimal line diffs with occurrence ids, subject to the given length limit
(falling back to locations only, per-file counts and finally a summary).
:param max_answer_chars: the maximum number of characters (must be positive)
:param dry_run: whether the listing is the result of a dry run (adding instructions on how to proceed)
:return: the listing
"""
affected_files = self.affected_files
header = f"Found {len(self.occurrences)} occurrence(s) in {len(affected_files)} file(s)."
if dry_run:
header += (
" DRY RUN - no changes were applied.\n"
"Re-issue with dry_run=False to replace all of them, or additionally pass occurrence_ids "
"with the ids of the occurrences to replace."
)
parts = [header]
for path in affected_files:
file_occurrences = [o for o in self.occurrences if o.relative_path == path]
parts.append(f"\n{path} ({len(file_occurrences)} occurrence(s)):")
for occ in file_occurrences:
parts.append(self._replacer.render_occurrence_diff(occ, self._contents[path]))
result = "\n".join(parts)
# shortened result closures, from least to most aggressive shortening
def make_locations_only() -> str:
return "\n".join([header] + [f" [{o.occurrence_id}] line {o.start_line}" for o in self.occurrences])
def make_per_file_counts() -> str:
counts = {path: sum(1 for o in self.occurrences if o.relative_path == path) for path in affected_files}
return f"{header}\nOccurrence counts per file:\n{TextOutputUtils.to_json(counts)}"
def make_summary() -> str:
return header
shortened_result_factories: list[Callable[[], str]] = [make_locations_only, make_per_file_counts, make_summary]
return TextOutputUtils.limit_length(result, max_answer_chars, shortened_result_factories)
def apply(self, code_editor: "CodeEditor", occurrences: list[ReplacementOccurrence]) -> MultiFileReplacementResult:
"""
Applies the given (selected) occurrences.
:param code_editor: the code editor through which to modify the files
:param occurrences: the occurrences to replace (obtained from `select` or `select_all_guarded`)
:return: the result
:raises ValueError: if a file's content changed such that a selected occurrence no longer resolves
(the file is then not modified)
"""
from serena.code_editor import EditedFileContext
occurrences_by_file: dict[str, list[ReplacementOccurrence]] = {}
for occ in occurrences:
occurrences_by_file.setdefault(occ.relative_path, []).append(occ)
for path, file_occurrences in occurrences_by_file.items():
with EditedFileContext(path, code_editor) as context:
original_content = context.get_original_content()
if original_content != self._contents[path]:
# the editor's view differs from what was scanned (e.g. line-ending normalization);
# re-derive the occurrences from the authoritative content and re-validate by id
fresh_by_id = {
o.occurrence_id: o for o in self._replacer.find_occurrences([(path, original_content)], self._needle, self._repl)
}
try:
file_occurrences = [fresh_by_id[o.occurrence_id] for o in file_occurrences]
except KeyError as e:
raise ValueError(
f"The content of {path} changed while replacing (occurrence {e} no longer resolves); "
f"the file was NOT modified. Re-run with dry_run=True for current ids."
) from e
context.set_updated_content(self._replacer.apply_to_content(original_content, file_occurrences))
return MultiFileReplacementResult({path: len(occs) for path, occs in occurrences_by_file.items()})
def find_text_coordinates(content: str, regex: str, require_unique: bool = False) -> TextCoordinates | None: def find_text_coordinates(content: str, regex: str, require_unique: bool = False) -> TextCoordinates | None:
""" """
Finds the line and column number of the first match of a regex pattern in the given content. Finds the line and column number of the first match of a regex pattern in the given content.
@@ -669,3 +938,39 @@ def find_text_coordinates(content: str, regex: str, require_unique: bool = False
index_in_content = match.start(1) index_in_content = match.start(1)
line, col = TextUtils.get_line_col_from_index(content, index_in_content) line, col = TextUtils.get_line_col_from_index(content, index_in_content)
return TextCoordinates(line, col) return TextCoordinates(line, col)
class TextOutputUtils:
@staticmethod
def to_json(x: Any) -> str:
return json.dumps(x, ensure_ascii=False)
@staticmethod
def limit_length(
result: str,
max_answer_chars: int,
shortened_result_factories: list[Callable[[], str]] | None = None,
) -> str:
"""Limit the length of the result string, optionally trying progressively shorter versions.
:param result: the full result string
:param max_answer_chars: maximum allowed characters; if exceeded, attempt to use shortened versions
:param shortened_result_factories: optional list of closures, each producing a progressively shorter
version of the result. They are tried in order until one fits within ``max_answer_chars``.
:return: the result string, potentially replaced by a shortened version
"""
if max_answer_chars <= 0:
raise ValueError(f"max_answer_chars must be positive; got: {max_answer_chars=}")
if (n_chars := len(result)) > max_answer_chars:
too_long_msg = (
f"The answer is too long ({n_chars} characters). " + "You can adjust your query or raise the max_answer_chars parameter."
)
if shortened_result_factories is not None:
# try each shortening closure in order;
for make_shorter in shortened_result_factories:
shortened = make_shorter()
candidate = f"{too_long_msg}\n{shortened}"
if len(candidate) <= max_answer_chars:
return candidate
result = too_long_msg
return result
+16 -3
View File
@@ -40,10 +40,11 @@ class InitializeParamsBuilder(ABC):
class DefaultInitializeParamsBuilder(InitializeParamsBuilder): class DefaultInitializeParamsBuilder(InitializeParamsBuilder):
def __init__(self, ls: "SolidLanguageServer", set_workspace_folders: bool = True): def __init__(self, ls: "SolidLanguageServer", set_workspace_folders: bool = True, set_root_uri: bool = True):
super().__init__() super().__init__()
self._ls = ls self._ls = ls
self._set_workspace_folders = set_workspace_folders self._set_workspace_folders = set_workspace_folders
self._set_root_uri = set_root_uri
@staticmethod @staticmethod
def _create_workspace_folder_entry(path: str) -> WorkspaceFolder: def _create_workspace_folder_entry(path: str) -> WorkspaceFolder:
@@ -54,10 +55,22 @@ class DefaultInitializeParamsBuilder(InitializeParamsBuilder):
root_abs_path = self._ls.repository_root_path root_abs_path = self._ls.repository_root_path
self._set("processId", os.getpid()) self._set("processId", os.getpid())
self._set("rootPath", root_abs_path)
self._set("rootUri", pathlib.Path(root_abs_path).as_uri())
self._set("clientInfo", {"name": "Serena"}) self._set("clientInfo", {"name": "Serena"})
# Some language servers treat rootUri as an additional analysis root on top of
# workspaceFolders, with no de-duplication, which can cause unbounded indexing.
# When set_root_uri is False, rootUri/rootPath are omitted so that workspaceFolders
# alone determine the analysis roots.
if self._set_root_uri:
self._set("rootPath", root_abs_path)
self._set("rootUri", pathlib.Path(root_abs_path).as_uri())
else:
# Some servers reject initialize when the key is absent
# ("params.rootUri must not be undefined"). Send explicit null so the
# field is present but not used as an analysis root (#2045).
self._set("rootPath", None)
self._set("rootUri", None)
if self._set_workspace_folders: if self._set_workspace_folders:
abs_workspace_paths = self._ls.config.get_absolute_workspace_folders(root_abs_path) abs_workspace_paths = self._ls.config.get_absolute_workspace_folders(root_abs_path)
log.info("Workspace folders: %s", abs_workspace_paths) log.info("Workspace folders: %s", abs_workspace_paths)
@@ -257,7 +257,7 @@ class CSharpLanguageServer(SolidLanguageServer):
return hover return hover
def _document_symbols_cache_fingerprint(self) -> Hashable | None: def _document_symbols_cache_fingerprint(self) -> Hashable | None:
normalize_symbol_name_version = 1 normalize_symbol_name_version = 2
return normalize_symbol_name_version return normalize_symbol_name_version
def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str:
@@ -301,15 +301,19 @@ class CSharpLanguageServer(SolidLanguageServer):
"Add(int, int) : int" -> ("Add", "(int, int) : int") "Add(int, int) : int" -> ("Add", "(int, int) : int")
"ToString()" -> ("ToString", "()") "ToString()" -> ("ToString", "()")
"SimpleMethod" -> ("SimpleMethod", "") "SimpleMethod" -> ("SimpleMethod", "")
"Position : (int X, string Y)" -> ("Position", ": (int X, string Y)")
Returns: Returns:
Tuple of (base_name, type_info) Tuple of (base_name, type_info)
""" """
# Check for property pattern: "Name : Type" # Check for property pattern: "Name : Type". The '(' guard must look only at the
if " : " in roslyn_name and "(" not in roslyn_name: # name segment before the first " : ", not the whole string, since a tuple type
# ("(int X, string Y)") legitimately contains parentheses.
if " : " in roslyn_name:
base_name, type_part = roslyn_name.split(" : ", 1) base_name, type_part = roslyn_name.split(" : ", 1)
return base_name.strip(), f": {type_part.strip()}" if "(" not in base_name:
return base_name.strip(), f": {type_part.strip()}"
# Check for method pattern: "MethodName(params) : ReturnType" # Check for method pattern: "MethodName(params) : ReturnType"
if "(" in roslyn_name: if "(" in roslyn_name:
@@ -744,11 +748,24 @@ class CSharpLanguageServer(SolidLanguageServer):
self.server.notify.send_notification("solution/open", {"solution": solution_uri}) self.server.notify.send_notification("solution/open", {"solution": solution_uri})
log.debug(f"Opened solution file: {solution_file}") log.debug(f"Opened solution file: {solution_file}")
# Find and open project files # Find and open project files, skipping any that the project's ignore settings exclude.
# Vendored, third-party and sample trees routinely contain .csproj files that the language
# server cannot restore or build. Each one costs a project load on every server start, and
# the resulting restore failures bury the diagnostics of the projects the user cares about.
project_files = [] project_files = []
skipped = 0
for filename in breadth_first_file_scan(self.repository_root_path): for filename in breadth_first_file_scan(self.repository_root_path):
if filename.endswith(".csproj"): if not filename.endswith(".csproj"):
project_files.append(filename) continue
relative_path = os.path.relpath(filename, self.repository_root_path)
# ignore_unsupported_files=False, because a .csproj is not itself a C# source file and
# would otherwise be excluded on file type rather than by the ignore patterns.
if self.is_ignored_path(relative_path, ignore_unsupported_files=False):
skipped += 1
continue
project_files.append(filename)
if skipped:
log.debug(f"Skipped {skipped} .csproj file(s) matched by the project's ignore settings")
# Send project/open notifications for each project file # Send project/open notifications for each project file
if project_files: if project_files:
@@ -7,6 +7,7 @@ from collections.abc import Hashable
from overrides import override from overrides import override
from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder
from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings from solidlsp.settings import SolidLSPSettings
@@ -73,6 +74,13 @@ class DartLanguageServer(SolidLanguageServer):
# via either notification it sends for this (see _start_server). # via either notification it sends for this (see _start_server).
self.analysis_complete = threading.Event() self.analysis_complete = threading.Event()
def _create_initialize_params_builder(self) -> InitializeParamsBuilder:
# The Dart analysis server treats rootUri as an additional analysis root on top of
# workspaceFolders, with no de-duplication, so on a monorepo root that is not a Dart
# package the whole tree is analysed and the server burns CPU at idle (oraios/serena#2045).
# Omit rootUri/rootPath and rely on workspaceFolders alone.
return DefaultInitializeParamsBuilder(self, set_root_uri=False)
@override @override
def _document_symbols_cache_fingerprint(self) -> Hashable: def _document_symbols_cache_fingerprint(self) -> Hashable:
normalize_symbol_name_version = 1 normalize_symbol_name_version = 1
@@ -10,8 +10,9 @@ The editor must be open with its built-in language server enabled (default).
import logging import logging
import os import os
from collections.abc import Callable from collections.abc import Callable
from typing import Any
from solidlsp.ls import SolidLanguageServer from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer
from solidlsp.lsp_protocol_handler.server import StringDict from solidlsp.lsp_protocol_handler.server import StringDict
@@ -38,6 +39,10 @@ class GodotLanguageServer(SolidLanguageServer):
- ``request_timeout`` (float): seconds to wait for an LSP response (default: 30.0). - ``request_timeout`` (float): seconds to wait for an LSP response (default: 30.0).
""" """
# Bump whenever _fix_range_end/_fix_symbol_ranges below changes, so a stale cached
# high-level result (from before this fix existed) is not served back to callers.
_DOCUMENT_SYMBOLS_CACHE_VERSION = 1
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None:
self._godot_version = self._detect_godot_version(repository_root_path) self._godot_version = self._detect_godot_version(repository_root_path)
if self._godot_version is not None: if self._godot_version is not None:
@@ -137,3 +142,66 @@ class GodotLanguageServer(SolidLanguageServer):
self.server.send.initialize(initialize_params) self.server.send.initialize(initialize_params)
self.server.notify.initialized({}) self.server.notify.initialized({})
log.info("Godot LSP initialized") log.info("Godot LSP initialized")
def _build_document_symbols_from_raw_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer) -> DocumentSymbols:
"""Override to correct a Godot GDScript parser off-by-one in reported end columns.
See :meth:`_fix_range_end` for the mechanism (oraios/serena#1974). Applied here, on the
converted high-level symbols, rather than in ``_request_raw_document_symbols`` or in the
generic ``TextUtils.get_index_from_line_col`` (used by every language server): the
overshoot is specific to Godot's own parser, not a property of LSP position math in
general, and post-processing at this level only needs to invalidate the high-level
symbol cache, not the (expensive to rebuild) raw one the language server itself answers.
TODO: gate this behind a Godot version check once the upstream parser bug is fixed
(tracked at https://github.com/godotengine/godot/issues, not yet filed there).
"""
document_symbols = super()._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer)
lines = file_buffer.split_lines()
for root_symbol in document_symbols.root_symbols:
self._fix_symbol_ranges(root_symbol, lines)
return document_symbols
def _document_symbols_cache_fingerprint(self) -> int:
return self._DOCUMENT_SYMBOLS_CACHE_VERSION
@staticmethod
def _fix_range_end(rng: Any, lines: list[str]) -> None:
"""Correct a Godot GDScript parser off-by-one in an LSP ``Range``'s end position, in place.
Godot's ``gdscript_parser.cpp`` closes a node's range using the *next* lookahead token
instead of the *last consumed* one. When that lookahead is a synthesized NEWLINE token,
``gdscript_tokenizer.cpp``'s ``newline()`` sets the token's own ``end_column`` to the
column reached *after* consuming the newline character, one past where a real content
token would end. Stacked on top of the usual one-past-the-end range convention, a symbol
whose body ends at that line reports an end column two past its last character instead of
one past it (oraios/serena#1974).
Only that exact, measured overshoot is corrected; a larger one is not this bug and is left
alone rather than guessed at.
"""
end = rng.get("end")
if end is None:
return
end_line, end_char = end.get("line"), end.get("character")
if end_line is None or end_char is None or not (0 <= end_line < len(lines)):
return
# The correct one-past-the-end column for this line is len(lines[end_line]).
correct_end_char = len(lines[end_line])
if end_char == correct_end_char + 1:
end["character"] = correct_end_char
@staticmethod
def _fix_symbol_ranges(symbol: Any, lines: list[str]) -> None:
"""Recursively apply :meth:`_fix_range_end` to a (raw or unified) symbol and its children."""
location = symbol.get("location")
if location is not None:
GodotLanguageServer._fix_range_end(location.get("range", {}), lines)
symbol_range = symbol.get("range")
if symbol_range is not None:
GodotLanguageServer._fix_range_end(symbol_range, lines)
selection_range = symbol.get("selectionRange")
if selection_range is not None:
GodotLanguageServer._fix_range_end(selection_range, lines)
for child in symbol.get("children") or []:
GodotLanguageServer._fix_symbol_ranges(child, lines)
@@ -6,7 +6,7 @@ You can configure the following options in ls_specific_settings (in serena_confi
ls_specific_settings: ls_specific_settings:
kotlin: kotlin:
ls_path: '/path/to/bin/intellij-server' # Custom path to Kotlin Language Server executable ls_path: '/path/to/bin/intellij-server' # Custom path to Kotlin Language Server executable
kotlin_lsp_version: '262.9593.0' # Kotlin Language Server version (default: current bundled version) kotlin_lsp_version: '263.4702.0' # Kotlin Language Server version (default: current bundled version)
jvm_options: '-Xmx2G' # JVM options for Kotlin Language Server (default: -Xmx2G) jvm_options: '-Xmx2G' # JVM options for Kotlin Language Server (default: -Xmx2G)
Example configuration for large projects: Example configuration for large projects:
@@ -50,7 +50,7 @@ KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",)
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. # DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
# NOTE: After changing either pinned version, run scripts/update_downloaded_dependency_hashes.py. # NOTE: After changing either pinned version, run scripts/update_downloaded_dependency_hashes.py.
INITIAL_KOTLIN_LSP_VERSION = "261.13587.0" INITIAL_KOTLIN_LSP_VERSION = "261.13587.0"
DEFAULT_KOTLIN_LSP_VERSION = "262.9593.0" DEFAULT_KOTLIN_LSP_VERSION = "263.4702.0"
# Versions before this one use kotlin-lsp-{version}-{platform}.zip and a kotlin-lsp script. # Versions before this one use kotlin-lsp-{version}-{platform}.zip and a kotlin-lsp script.
# Starting with 262.4739.0, JetBrains publishes kotlin-server archives with platform-specific # Starting with 262.4739.0, JetBrains publishes kotlin-server archives with platform-specific
+3 -5
View File
@@ -546,7 +546,7 @@ class SolidLanguageServer(ABC):
self._published_diagnostics_condition = threading.Condition() self._published_diagnostics_condition = threading.Condition()
# initialise symbol caches # initialise symbol caches
self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.ls_id.get_key()
self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True)
# * raw document symbols cache # * raw document symbols cache
self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols
@@ -2984,10 +2984,8 @@ class SolidLanguageServer(ABC):
high_level_fingerprint = self._document_symbols_cache_fingerprint() high_level_fingerprint = self._document_symbols_cache_fingerprint()
if high_level_fingerprint is not None: if high_level_fingerprint is not None:
version.append(high_level_fingerprint) version.append(high_level_fingerprint)
raw_fingerprint = self._raw_document_symbols_cache_fingerprint() version.append(self._raw_document_symbols_cache_version())
if raw_fingerprint is not None: return tuple(version)
version.append(raw_fingerprint)
return version[0] if len(version) == 1 else tuple(version)
def _save_raw_document_symbols_cache(self) -> None: def _save_raw_document_symbols_cache(self) -> None:
cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME
+9 -1
View File
@@ -10,7 +10,7 @@ import logging
import os import os
import re import re
import threading import threading
from collections.abc import Iterable from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from functools import cache from functools import cache
@@ -1066,6 +1066,14 @@ class LanguageServerRegistry:
return self._registered_language_servers[key] return self._registered_language_servers[key]
raise ValueError(f"Unknown language server key: '{key}'; Valid keys: {self.get_keys()}") raise ValueError(f"Unknown language server key: '{key}'; Valid keys: {self.get_keys()}")
def iter_registered_ls_ids(self) -> Iterator[LanguageServerIdLike]:
"""
Iterate over all registered language servers (built-in + externally-registered via
entry points). Order follows ``get_keys()`` (alphabetical).
"""
for key in self.get_keys():
yield self._registered_language_servers[key]
def register(self, ls_id: LanguageServerIdLike, allow_override: bool = False) -> None: def register(self, ls_id: LanguageServerIdLike, allow_override: bool = False) -> None:
""" """
:param ls_id: the identifier to register :param ls_id: the identifier to register
@@ -16,5 +16,11 @@
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.tar.gz": "2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e", "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.tar.gz": "2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.tar.gz": "2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e", "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.tar.gz": "2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.sit": "17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745", "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.sit": "17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.sit": "6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f" "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.sit": "6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f",
} "https://download-cdn.jetbrains.com/language-server/kotlin-server/263.4702.0/kotlin-server-263.4702.0.win.zip": "a9b471b16025b1bfb3b0a097862580abb40e3c35406c44242c18b1d70f5d0e44",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/263.4702.0/kotlin-server-263.4702.0-aarch64.win.zip": "3bf008d8c94fa70eb13fc998eaa42f29b9d13f368984d4cec46277808f94e1de",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/263.4702.0/kotlin-server-263.4702.0.tar.gz": "1e11d2e5fefbf9ea215ad8dd6be95f2222897cd086e8cb7a661a52084a590405",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/263.4702.0/kotlin-server-263.4702.0-aarch64.tar.gz": "ec7cb254a6662a07fff9f10e4365226afab6c40008f8a974c10ac5e785d6510f",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/263.4702.0/kotlin-server-263.4702.0.sit": "62ab735947b1c855b505f64f5db8fbd7ff0b52a35ab1897938c6dbfc7b24c8a3",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/263.4702.0/kotlin-server-263.4702.0-aarch64.sit": "95da3fc6d3b9092c7616345044a05edb85e5408dc648d081e4e433595c892bec"
}
+143 -18
View File
@@ -4,19 +4,21 @@ import shutil
import tempfile import tempfile
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from uuid import UUID
import pytest import pytest
from serena.agent import SerenaAgent from serena.agent import SerenaAgent
from serena.config.serena_config import ( from serena.config.serena_config import (
DEFAULT_PROJECT_SERENA_FOLDER_LOCATION, DEFAULT_PROJECT_SERENA_FOLDER_LOCATION,
LanguageBackend, AgentInterface,
ProjectConfig, ProjectConfig,
RegisteredProject, RegisteredProject,
SerenaConfig, SerenaConfig,
SerenaConfigError, SerenaConfigError,
) )
from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME
from serena.language_backend import BuiltinLanguageBackend
from serena.project import MemoryManager, Project from serena.project import MemoryManager, Project
from solidlsp.ls_config import LanguageServerId from solidlsp.ls_config import LanguageServerId
from test.conftest import create_default_serena_config from test.conftest import create_default_serena_config
@@ -176,15 +178,16 @@ class TestProjectConfigLanguageBackend:
config = ProjectConfig( config = ProjectConfig(
project_name="test", project_name="test",
language_servers=[LanguageServerId.PYTHON], language_servers=[LanguageServerId.PYTHON],
language_backend=LanguageBackend.JETBRAINS, language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
) )
assert config.language_backend == LanguageBackend.JETBRAINS assert config.language_backend is not None
assert config.language_backend.is_jetbrains()
def test_language_backend_roundtrips_through_yaml(self): def test_language_backend_roundtrips_through_yaml(self):
config = ProjectConfig( config = ProjectConfig(
project_name="test", project_name="test",
language_servers=[LanguageServerId.PYTHON], language_servers=[LanguageServerId.PYTHON],
language_backend=LanguageBackend.JETBRAINS, language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
) )
d = config._to_yaml_dict() d = config._to_yaml_dict()
assert d["language_backend"] == "JetBrains" assert d["language_backend"] == "JetBrains"
@@ -205,7 +208,8 @@ class TestProjectConfigLanguageBackend:
data["languages"] = ["python"] data["languages"] = ["python"]
data["language_backend"] = "JetBrains" data["language_backend"] = "JetBrains"
config = ProjectConfig._from_dict(data, local_override_keys=[]) config = ProjectConfig._from_dict(data, local_override_keys=[])
assert config.language_backend == LanguageBackend.JETBRAINS assert config.language_backend is not None
assert config.language_backend.is_jetbrains()
def test_language_backend_none_when_missing_from_dict(self): def test_language_backend_none_when_missing_from_dict(self):
"""Test that _from_dict handles missing language_backend gracefully.""" """Test that _from_dict handles missing language_backend gracefully."""
@@ -218,22 +222,84 @@ class TestProjectConfigLanguageBackend:
assert config.language_backend is None assert config.language_backend is None
class TestAgentInterface:
"""Tests for the agent_interface setting (global and per project)."""
@staticmethod
def _project_config(agent_interface: AgentInterface | None) -> ProjectConfig:
return ProjectConfig(project_name="test", language_servers=[LanguageServerId.PYTHON], agent_interface=agent_interface)
def test_agent_interface_roundtrips_through_project_yaml(self):
assert self._project_config(AgentInterface.REPL)._to_yaml_dict()["agent_interface"] == "REPL"
assert self._project_config(None)._to_yaml_dict()["agent_interface"] is None
def test_agent_interface_parsed_from_project_dict(self):
data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE)
data["project_name"] = "test"
data["languages"] = ["python"]
data["agent_interface"] = "repl" # case-insensitive
assert ProjectConfig._from_dict(data, local_override_keys=[]).agent_interface == AgentInterface.REPL
data.pop("agent_interface")
assert ProjectConfig._from_dict(data, local_override_keys=[]).agent_interface is None
def test_determine_agent_interface_precedence(self):
# default
assert SerenaConfig().determine_agent_interface() == AgentInterface.TOOLS
assert SerenaConfig().determine_agent_interface(self._project_config(None)) == AgentInterface.TOOLS
# global configuration
assert SerenaConfig(agent_interface=AgentInterface.REPL).determine_agent_interface() == AgentInterface.REPL
# project configuration takes precedence
config = SerenaConfig(agent_interface=AgentInterface.REPL)
assert config.determine_agent_interface(self._project_config(AgentInterface.TOOLS)) == AgentInterface.TOOLS
assert config.determine_agent_interface(self._project_config(None)) == AgentInterface.REPL
def test_repl_toolset_is_fixed_and_repl_follows_project_activation(self):
"""
In REPL mode, neither the exposed nor the active toolset is affected by tool inclusion/exclusion definitions
(here: the project's exclusions and read-only setting), whereas the REPL's API scope follows the active project.
"""
config, name = _make_config_with_project("test_proj")
config.agent_interface = AgentInterface.REPL
project_config = config.projects[0].project_config
project_config.excluded_tools = ["initial_instructions", "serena_repl"]
project_config.excluded_apis = ["mem"]
project_config.read_only = True
agent = SerenaAgent(project=None, serena_config=config)
try:
# before activation: the fixed toolset and the full set of facades
fixed_toolset = {"serena_repl", "initial_instructions", "activate_project"}
assert {t.get_name() for t in agent.get_exposed_tool_instances()} == fixed_toolset
assert set(agent.get_active_tool_names()) == fixed_toolset
overview = agent.get_repl().entrypoint.overview()
assert "s.mem" in overview
# the dashboard is disabled in the test configuration, so opening it is not offered
assert "s.cfg" in overview and "open_dashboard" not in overview
# after activation: the toolset is unchanged, the REPL reflects the project's API exclusions
agent.activate_project_from_path_or_name(name)
assert set(agent.get_active_tool_names()) == fixed_toolset
assert "s.mem" not in agent.get_repl().entrypoint.overview()
finally:
agent.on_shutdown(timeout=5)
def _make_config_with_project( def _make_config_with_project(
project_name: str, project_name: str,
language_backend: LanguageBackend | None = None, language_backend: BuiltinLanguageBackend | None = None,
global_backend: LanguageBackend = LanguageBackend.LSP, global_backend: BuiltinLanguageBackend = BuiltinLanguageBackend.LSP,
) -> tuple[SerenaConfig, str]: ) -> tuple[SerenaConfig, str]:
"""Create a SerenaConfig with a single registered project and return (config, project_name).""" """Create a SerenaConfig with a single registered project and return (config, project_name)."""
config = SerenaConfig( config = SerenaConfig(
log_level=logging.ERROR, log_level=logging.ERROR,
language_backend=global_backend, language_backend=global_backend.get_instance(),
).with_headless_mode_overrides() ).with_headless_mode_overrides()
project = Project( project = Project(
project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"),
project_config=ProjectConfig( project_config=ProjectConfig(
project_name=project_name, project_name=project_name,
language_servers=[LanguageServerId.PYTHON], language_servers=[LanguageServerId.PYTHON],
language_backend=language_backend, language_backend=language_backend.get_instance() if language_backend is not None else None,
), ),
serena_config=config, serena_config=config,
) )
@@ -246,7 +312,7 @@ class TestEffectiveLanguageBackend:
def test_default_backend_is_global(self): def test_default_backend_is_global(self):
"""When no project override, effective backend matches global config.""" """When no project override, effective backend matches global config."""
config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=LanguageBackend.LSP) config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
agent = SerenaAgent(project=name, serena_config=config) agent = SerenaAgent(project=name, serena_config=config)
try: try:
assert agent.get_language_backend().is_lsp() assert agent.get_language_backend().is_lsp()
@@ -256,7 +322,7 @@ class TestEffectiveLanguageBackend:
def test_project_overrides_global_backend(self): def test_project_overrides_global_backend(self):
"""When startup project has language_backend set, it overrides the global.""" """When startup project has language_backend set, it overrides the global."""
config, name = _make_config_with_project( config, name = _make_config_with_project(
"test_jetbrains", language_backend=LanguageBackend.JETBRAINS, global_backend=LanguageBackend.LSP "test_jetbrains", language_backend=BuiltinLanguageBackend.JETBRAINS, global_backend=BuiltinLanguageBackend.LSP
) )
agent = SerenaAgent(project=name, serena_config=config) agent = SerenaAgent(project=name, serena_config=config)
try: try:
@@ -268,18 +334,18 @@ class TestEffectiveLanguageBackend:
"""When no startup project is provided, effective backend is the global one.""" """When no startup project is provided, effective backend is the global one."""
config = SerenaConfig( config = SerenaConfig(
log_level=logging.ERROR, log_level=logging.ERROR,
language_backend=LanguageBackend.LSP, language_backend=BuiltinLanguageBackend.LSP.get_instance(),
).with_headless_mode_overrides() ).with_headless_mode_overrides()
agent = SerenaAgent(project=None, serena_config=config) agent = SerenaAgent(project=None, serena_config=config)
try: try:
assert agent.get_language_backend() == LanguageBackend.LSP assert agent.get_language_backend().is_lsp()
finally: finally:
agent.on_shutdown(timeout=5) agent.on_shutdown(timeout=5)
def test_activate_project_rejects_backend_mismatch(self): def test_activate_project_rejects_backend_mismatch(self):
"""Post-init activation of a project with mismatched backend raises ValueError.""" """Post-init activation of a project with mismatched backend raises ValueError."""
# Start with LSP backend # Start with LSP backend
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
# Add a second project that requires JetBrains # Add a second project that requires JetBrains
jb_project = Project( jb_project = Project(
@@ -287,7 +353,7 @@ class TestEffectiveLanguageBackend:
project_config=ProjectConfig( project_config=ProjectConfig(
project_name="jb_proj", project_name="jb_proj",
language_servers=[LanguageServerId.JAVA], language_servers=[LanguageServerId.JAVA],
language_backend=LanguageBackend.JETBRAINS, language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
), ),
serena_config=config, serena_config=config,
) )
@@ -300,9 +366,38 @@ class TestEffectiveLanguageBackend:
finally: finally:
agent.on_shutdown(timeout=5) agent.on_shutdown(timeout=5)
def test_activate_project_switches_backend_with_repl_interface(self):
"""With the REPL interface, post-init activation of a project with a different backend switches the backend."""
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
config.agent_interface = AgentInterface.REPL
jb_project = Project(
project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "java" / "test_repo"),
project_config=ProjectConfig(
project_name="jb_proj",
language_servers=[LanguageServerId.JAVA],
language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
),
serena_config=config,
)
config.projects.append(RegisteredProject.from_project_instance(jb_project))
agent = SerenaAgent(project=name, serena_config=config)
try:
assert agent.get_language_backend().is_lsp()
assert "s.lsp" in agent.get_repl().entrypoint.overview()
# the backend and everything depending on it follow the activated project
agent.activate_project_from_path_or_name("jb_proj")
assert agent.get_language_backend().is_jetbrains()
overview = agent.get_repl().entrypoint.overview()
assert "s.jb" in overview and "s.lsp" not in overview
assert "jetbrains" in [m.name for m in agent.get_active_modes().get_modes(include_background_base_modes=True)]
finally:
agent.on_shutdown(timeout=5)
def test_activate_project_allows_matching_backend(self): def test_activate_project_allows_matching_backend(self):
"""Post-init activation of a project with matching backend succeeds.""" """Post-init activation of a project with matching backend succeeds."""
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
# Add a second project that also uses LSP # Add a second project that also uses LSP
lsp_project2 = Project( lsp_project2 = Project(
@@ -310,7 +405,7 @@ class TestEffectiveLanguageBackend:
project_config=ProjectConfig( project_config=ProjectConfig(
project_name="lsp_proj2", project_name="lsp_proj2",
language_servers=[LanguageServerId.PYTHON], language_servers=[LanguageServerId.PYTHON],
language_backend=LanguageBackend.LSP, language_backend=BuiltinLanguageBackend.LSP.get_instance(),
), ),
serena_config=config, serena_config=config,
) )
@@ -325,7 +420,7 @@ class TestEffectiveLanguageBackend:
def test_activate_project_allows_none_backend(self): def test_activate_project_allows_none_backend(self):
"""Post-init activation of a project with no backend override succeeds.""" """Post-init activation of a project with no backend override succeeds."""
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
# Add a second project with no backend override # Add a second project with no backend override
proj2 = Project( proj2 = Project(
@@ -543,6 +638,36 @@ class TestSerenaConfigLoadSave:
config = SerenaConfig.from_config_file(generate_if_missing=False) config = SerenaConfig.from_config_file(generate_if_missing=False)
assert config.projects == [] assert config.projects == []
@pytest.mark.parametrize("setting", ["", "auth_secret: null\n", 'auth_secret: ""\n'])
def test_unset_auth_secret_is_generated_and_persisted(self, setting: str) -> None:
# load an existing configuration without a usable secret
self.master_config_path.write_text("projects: []\n" + setting)
config = SerenaConfig.from_config_file(generate_if_missing=False)
# subsequent loads retain the generated random UUID
assert UUID(config.auth_secret).version == 4
assert SerenaConfig.from_config_file(generate_if_missing=False).auth_secret == config.auth_secret
def test_configured_auth_secret_is_preserved(self) -> None:
# retain a user-provided secret across loading and migration
self.master_config_path.write_text("projects: []\nauth_secret: custom-secret\n")
assert SerenaConfig.from_config_file(generate_if_missing=False).auth_secret == "custom-secret"
assert SerenaConfig.from_config_file(generate_if_missing=False).auth_secret == "custom-secret"
def test_new_config_has_persistent_auth_secret(self) -> None:
# generate the configuration from the template and retain its secret
config = SerenaConfig.from_config_file()
assert UUID(config.auth_secret).version == 4
assert SerenaConfig.from_config_file().auth_secret == config.auth_secret
def test_direct_config_instances_have_distinct_auth_secrets(self) -> None:
# directly constructed configurations receive independent secrets
first = SerenaConfig()
second = SerenaConfig()
assert UUID(first.auth_secret).version == 4
assert UUID(second.auth_secret).version == 4
assert first.auth_secret != second.auth_secret
def test_malformed_project_is_skipped_with_warning(self, caplog): def test_malformed_project_is_skipped_with_warning(self, caplog):
"""A malformed project.yml must not abort loading of the others.""" """A malformed project.yml must not abort loading of the others."""
good_project = self._make_project_dir( good_project = self._make_project_dir(
+29
View File
@@ -0,0 +1,29 @@
"""The anthropic package must only be loaded when the Anthropic token counter is used (#2012)."""
import subprocess
import sys
from unittest.mock import MagicMock
import pytest
from serena.analytics import AnthropicTokenCount
@pytest.mark.parametrize("module", ["serena.cli", "serena.agent", "serena.analytics"])
def test_importing_serena_does_not_load_anthropic(module: str) -> None:
code = f"import sys, {module}; print('anthropic' in sys.modules)"
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True, timeout=120)
assert result.stdout.strip() == "False", f"importing {module} loaded the anthropic package"
def test_anthropic_token_count_sends_a_plain_user_message() -> None:
estimator = AnthropicTokenCount.__new__(AnthropicTokenCount)
estimator._model_name = "claude-sonnet-4-20250514"
estimator._anthropic_client = MagicMock()
estimator._anthropic_client.messages.count_tokens.return_value = MagicMock(input_tokens=7)
assert estimator.estimate_token_count("hello") == 7
estimator._anthropic_client.messages.count_tokens.assert_called_once_with(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "hello"}],
)
+25
View File
@@ -0,0 +1,25 @@
"""
Tests for the configuration facade API.
"""
from unittest.mock import MagicMock
from serena.repl.api.cfg_api import ConfigApi
from serena.repl.facade import ApiScope, Facade
def test_facade_exposes_config_operations() -> None:
facade = Facade.from_api(ConfigApi(MagicMock()), ApiScope())
assert facade.name == "cfg"
assert set(facade.enabled_method_names) == {"get_current_config", "open_dashboard"}
assert not any(facade.get_method(name).info.can_edit for name in facade.enabled_method_names)
def test_operations_delegate_to_agent() -> None:
agent = MagicMock()
agent.get_current_config_overview.return_value = "overview"
agent.open_dashboard.return_value = False
agent.get_dashboard_url.return_value = "http://localhost:1"
api = ConfigApi(agent)
assert api.get_current_config() == "overview"
assert "http://localhost:1" in api.open_dashboard()
+123
View File
@@ -0,0 +1,123 @@
"""Tests for the CLI's ``project remove`` command."""
import shutil
import tempfile
from pathlib import Path
import pytest
from click.testing import CliRunner
from serena.cli import ProjectCommands
from serena.config.serena_config import SerenaConfig
from serena.constants import SERENA_MANAGED_DIR_NAME
class TestProjectRemove:
"""
Drives ``serena project remove`` against a temporary Serena configuration file, so the
user's real project registry is never touched.
"""
@pytest.fixture(autouse=True)
def setup(self, monkeypatch):
self.test_dir = Path(tempfile.mkdtemp())
self.master_config_path = self.test_dir / "serena_config.yml"
monkeypatch.setattr(
SerenaConfig,
"_determine_config_file_path",
classmethod(lambda cls: str(self.master_config_path)),
)
self.runner = CliRunner()
def teardown_method(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def _make_project_dir(self, dir_name: str, project_name: str | None = None) -> Path:
project_dir = self.test_dir / dir_name
(project_dir / SERENA_MANAGED_DIR_NAME).mkdir(parents=True)
(project_dir / SERENA_MANAGED_DIR_NAME / "project.yml").write_text(
f'project_name: "{project_name or dir_name}"\nlanguages: ["python"]\n'
)
return project_dir
def _write_master_config(self, project_paths: list[Path]) -> None:
self.master_config_path.write_text("projects:\n" + "".join(f" - {p}\n" for p in project_paths))
def _registered_roots(self) -> set[Path]:
config = SerenaConfig.from_config_file(generate_if_missing=False)
return {Path(project.project_root) for project in config.projects}
def test_remove_by_name_unregisters_only_that_project(self):
keep = self._make_project_dir("keep")
drop = self._make_project_dir("drop")
self._write_master_config([keep, drop])
result = self.runner.invoke(ProjectCommands.remove, ["drop"])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert self._registered_roots() == {keep.resolve()}
def test_remove_by_path_unregisters_only_that_project(self):
keep = self._make_project_dir("keep")
drop = self._make_project_dir("drop")
self._write_master_config([keep, drop])
result = self.runner.invoke(ProjectCommands.remove, [str(drop)])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert self._registered_roots() == {keep.resolve()}
def test_remove_names_the_project_it_removed(self):
drop = self._make_project_dir("drop")
self._write_master_config([drop])
result = self.runner.invoke(ProjectCommands.remove, [str(drop)])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "drop" in result.output
assert str(drop.resolve()) in result.output
def test_remove_keeps_the_project_configuration_file_on_disk(self):
"""Unregistering must not delete the project's own files; re-registering it must remain possible."""
drop = self._make_project_dir("drop")
self._write_master_config([drop])
result = self.runner.invoke(ProjectCommands.remove, [str(drop)])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert (drop / SERENA_MANAGED_DIR_NAME / "project.yml").is_file()
def test_remove_unknown_project_fails_without_changing_the_registry(self):
keep = self._make_project_dir("keep")
self._write_master_config([keep])
result = self.runner.invoke(ProjectCommands.remove, ["no_such_project"])
assert result.exit_code != 0
assert "no_such_project" in result.output
assert self._registered_roots() == {keep.resolve()}
def test_remove_by_path_picks_the_entry_at_that_path_when_names_collide(self):
"""Two directories may carry the same ``project_name``; a path must remove the entry at that path."""
first = self._make_project_dir("first_dir", project_name="twin")
second = self._make_project_dir("second_dir", project_name="twin")
self._write_master_config([first, second])
result = self.runner.invoke(ProjectCommands.remove, [str(second)])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert self._registered_roots() == {first.resolve()}
def test_remove_by_ambiguous_name_fails_with_a_message_rather_than_a_traceback(self):
first = self._make_project_dir("first_dir", project_name="twin")
second = self._make_project_dir("second_dir", project_name="twin")
self._write_master_config([first, second])
result = self.runner.invoke(ProjectCommands.remove, ["twin"])
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit), (
f"Expected a handled CLI error, got: {result.exception!r}"
)
assert "twin" in result.output
assert self._registered_roots() == {first.resolve(), second.resolve()}
@@ -0,0 +1,199 @@
"""Tests that saving an edited source file cannot destroy the previous content (issue #1958).
``CodeEditor._save_edited_file`` is the third of the three direct ``open(path, "w")`` writes the
issue lists; the two in ``MemoryManager`` were addressed in #1969, which is also where
``write_file_atomic`` comes from.
The editor is exercised through a stub subclass so that the inherited save path can be driven
without a language server.
"""
import os
import stat
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import pytest
from serena.code_editor import CodeEditor
from serena.language_backend import BuiltinLanguageBackend
from serena.util import file_system
class _InMemoryEditedFile(CodeEditor.EditedFile):
"""An ``EditedFile`` that holds its contents in memory."""
def __init__(self, relative_path: str, contents: str) -> None:
super().__init__(relative_path)
self._contents = contents
def get_contents(self) -> str:
return self._contents
def set_contents(self, contents: str) -> None:
self._contents = contents
def delete_text_between_positions(self, start_pos: Any, end_pos: Any) -> None:
raise NotImplementedError
def insert_text_at_position(self, pos: Any, text: str) -> None:
raise NotImplementedError
class _StubCodeEditor(CodeEditor[Any]):
"""A ``CodeEditor`` whose only inherited behaviour under test is the file-saving path."""
class DummyProject:
"""A dummy project object with only the attributes needed to construct a ``CodeEditor``."""
def __init__(self) -> None:
self.language_backend = BuiltinLanguageBackend.LSP.get_instance()
def __init__(self, project_root: str, encoding: str = "utf-8", newline: str | None = None) -> None:
self.project = self.DummyProject()
self.project_root = project_root
self.encoding = encoding
self.newline = newline
@contextmanager
def _open_file_context(self, relative_path: str) -> Iterator[CodeEditor.EditedFile]:
abs_path = os.path.join(self.project_root, relative_path)
with open(abs_path, encoding=self.encoding) as f:
contents = f.read()
yield _InMemoryEditedFile(relative_path, contents)
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> Any:
raise NotImplementedError
def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str:
raise NotImplementedError
class TestSourceFileSaveIsAtomic:
def _editor(self, tmp_path: Any, **kwargs: Any) -> _StubCodeEditor:
return _StubCodeEditor(str(tmp_path), **kwargs)
def test_interrupted_save_keeps_the_previous_file_content(self, tmp_path, monkeypatch):
"""A crash partway through the write must leave the file holding its old content.
The crash is injected into the temp-file write that ``write_file_atomic`` performs, so on
a non-atomic implementation nothing raises at all and this fails with ``DID NOT RAISE``,
which is exactly the state the issue describes: the real file is truncated first and there
is no intermediate copy to fall back to.
"""
source = tmp_path / "module.py"
original = "def original():\n return 1\n" * 40
source.write_text(original, encoding="utf-8")
real_fdopen = os.fdopen
def crashing_fdopen(fd: int, *args: Any, **kwargs: Any) -> Any:
f = real_fdopen(fd, *args, **kwargs)
real_write = f.write
def crashing_write(data: str) -> int:
real_write(data[: len(data) // 4])
f.flush()
raise RuntimeError("simulated crash mid-write")
f.write = crashing_write
return f
monkeypatch.setattr(file_system.os, "fdopen", crashing_fdopen)
editor = self._editor(tmp_path)
with pytest.raises(RuntimeError, match="simulated crash mid-write"):
with editor.edited_file_context("module.py") as edited:
edited.set_contents("def replacement():\n return 2\n" * 40)
assert source.read_text(encoding="utf-8") == original
assert list(tmp_path.iterdir()) == [source], "the partial temp file must not be left behind"
def test_successful_save_writes_the_new_content(self, tmp_path):
"""Control: the ordinary path still writes what was asked for."""
source = tmp_path / "module.py"
source.write_text("old\n", encoding="utf-8")
editor = self._editor(tmp_path)
with editor.edited_file_context("module.py") as edited:
edited.set_contents("new\n")
assert source.read_text(encoding="utf-8") == "new\n"
assert list(tmp_path.iterdir()) == [source]
def test_save_into_a_subdirectory(self, tmp_path):
"""Every other test writes at the project root; the relative path is joined and resolved,
so a nested file has to work the same way.
"""
package = tmp_path / "pkg" / "sub"
package.mkdir(parents=True)
source = package / "module.py"
source.write_text("old\n", encoding="utf-8")
editor = self._editor(tmp_path)
with editor.edited_file_context("pkg/sub/module.py") as edited:
edited.set_contents("new\n")
assert source.read_text(encoding="utf-8") == "new\n"
assert list(package.iterdir()) == [source], "no temp file may be left beside the source"
def test_save_writes_through_a_symlinked_source_file(self, tmp_path):
"""A symlinked source file must keep being written through to its target, as
``open(path, "w")`` did; the link itself must not be replaced by a regular file.
"""
target_dir = tmp_path / "shared"
target_dir.mkdir()
target = target_dir / "shared.py"
target.write_text("old\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir()
link = project / "module.py"
try:
link.symlink_to(target)
except OSError as e:
pytest.skip(f"cannot create symlinks on this platform/permissions: {e}")
editor = self._editor(project)
with editor.edited_file_context("module.py") as edited:
edited.set_contents("new\n")
assert link.is_symlink(), "the source file's symlink must survive the edit"
assert target.read_text(encoding="utf-8") == "new\n", "the edit must reach the link's target"
@pytest.mark.skipif(
sys.platform == "win32", reason="Windows does not model POSIX permission bits; chmod only toggles the read-only flag"
)
def test_save_preserves_the_executable_bit(self, tmp_path):
script = tmp_path / "run.sh"
script.write_text("#!/bin/sh\necho old\n", encoding="utf-8")
os.chmod(script, 0o755)
editor = self._editor(tmp_path)
with editor.edited_file_context("run.sh") as edited:
edited.set_contents("#!/bin/sh\necho new\n")
assert stat.S_IMODE(os.stat(script).st_mode) == 0o755
def test_save_respects_the_configured_newline(self, tmp_path):
source = tmp_path / "module.py"
source.write_bytes(b"old\n")
editor = self._editor(tmp_path, newline="\r\n")
with editor.edited_file_context("module.py") as edited:
edited.set_contents("a\nb\n")
assert source.read_bytes() == b"a\r\nb\r\n"
def test_save_respects_the_configured_encoding(self, tmp_path):
source = tmp_path / "module.py"
source.write_text("alt\n", encoding="latin-1")
# newline is pinned so this test is about the encoding alone: LineEnding.NATIVE yields
# newline=None, under which Python translates "\n" to os.linesep on write
editor = self._editor(tmp_path, encoding="latin-1", newline="\n")
with editor.edited_file_context("module.py") as edited:
edited.set_contents("café\n")
assert source.read_bytes() == "café\n".encode("latin-1")
+68
View File
@@ -0,0 +1,68 @@
"""
Tests for the editing facade API (backend-independent parts, without a language server).
"""
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from serena.config.serena_config import SerenaConfig
from serena.project import Project
from serena.repl.api.edit_api import EditApi, ReplacementPreview
from serena.repl.facade import ApiScope, Facade
@pytest.fixture
def project(tmp_path: Path) -> Project:
(tmp_path / "a.py").write_text("x = foo(1)\ny = foo(2)\n", encoding="utf-8")
(tmp_path / "b.py").write_text("z = foo(3)\n", encoding="utf-8")
return Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False))
@pytest.fixture
def api(project: Project) -> EditApi:
agent = MagicMock()
agent.get_active_project_or_raise.return_value = project
agent.serena_config.default_max_tool_answer_chars = 10000
return EditApi(agent)
def test_facade_exposes_editing_operations(api: EditApi) -> None:
default_methods = {
"replace_content",
"replace_in_files",
"replace_symbol_body",
"insert_after_symbol",
"insert_before_symbol",
}
optional_methods = {"delete_lines", "replace_lines", "insert_at_line"} # the line-level operations are optional (as are the tools)
facade = Facade.from_api(api, ApiScope())
assert facade.name == "edit"
assert set(facade.enabled_method_names) == default_methods
for name in optional_methods:
assert facade.get_method(name).info.optional
assert all(facade.get_method(name).info.can_edit for name in default_methods | optional_methods)
def test_replace_in_files_dry_run_returns_inspectable_preview(api: EditApi, project: Project) -> None:
preview = api.replace_in_files("foo", "bar", mode="literal", dry_run=True)
assert isinstance(preview, ReplacementPreview)
# the occurrences are accessible from code
assert [o.relative_path for o in preview.occurrences] == ["a.py", "a.py", "b.py"]
assert preview.affected_files == ["a.py", "b.py"]
assert all(o.replacement == "bar" for o in preview.occurrences)
# the rendering lists the occurrence ids and diffs; nothing was modified
rendered = preview.represent()
assert "DRY RUN" in rendered
assert all(o.occurrence_id in rendered for o in preview.occurrences)
assert (Path(project.project_root) / "a.py").read_text(encoding="utf-8") == "x = foo(1)\ny = foo(2)\n"
def test_replace_in_files_guard_failure_includes_preview(api: EditApi) -> None:
with pytest.raises(ValueError, match="expected_count=1") as exc_info:
api.replace_in_files("foo", "bar", mode="literal", expected_count=1)
assert "b.py" in str(exc_info.value) # the listing of prospective changes is included
+107
View File
@@ -0,0 +1,107 @@
"""
End-to-end test of querying an external project through the REPL: a project server executes the language server
operations of the queried project, and the (pickled) results are usable in the querying agent's REPL.
"""
import socket
import threading
from collections.abc import Iterator
import pytest
from werkzeug.serving import make_server
from serena.agent import SerenaAgent
from serena.config.serena_config import SerenaConfig
from serena.project_server import ProjectServer, ProjectServerClient
from serena.repl.api.lsp_api import LspSymbolCollection
from serena.tools import SerenaReplTool
from solidlsp.ls_config import LanguageServerId
from test.conftest import language_server_tests_enabled
from test.serena.test_serena_agent import serena_config # noqa: F401 (fixture)
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture
def project_server(serena_config: SerenaConfig) -> Iterator[tuple[ProjectServer, int]]: # noqa: F811
"""
Runs a project server (backed by a real agent) on a free port for the duration of the test.
"""
config = serena_config
port = _free_port()
# construct the server around an agent with the test configuration (the constructor would load the user's configuration)
server = ProjectServer.__new__(ProjectServer)
server._agent = SerenaAgent(serena_config=config)
server._loaded_projects_by_root = {}
server._project_load_locks_by_root = {}
server._active_project_lock = threading.Lock()
server._loaded_projects_lock = threading.Lock()
server._port = port
server._host = "127.0.0.1"
from flask import Flask
server._app = Flask(__name__)
server._setup_routes()
http_server = make_server("127.0.0.1", port, server._app, threaded=True)
thread = threading.Thread(target=http_server.serve_forever, daemon=True)
thread.start()
try:
yield server, port
finally:
http_server.shutdown()
server._agent.on_shutdown(timeout=5)
@pytest.mark.python
@pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.PYTHON), reason="python tests are disabled in this environment")
def test_facade_method_results_are_transferred_from_the_project_server(project_server: tuple[ProjectServer, int]) -> None:
server, port = project_server
client = ProjectServerClient(server.get_serena_config(), port=port)
result = client.call_facade_method("test_repo_python", "lsp", "find_symbol", ["create_user"], {"include_body": True})
# the result is a self-contained object which can be processed and rendered locally
assert isinstance(result, LspSymbolCollection)
assert [s.name for s in result.symbols] == ["create_user"]
assert result.symbols[0].body.startswith("def create_user")
assert "create_user" in result.represent()
@pytest.mark.python
@pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.PYTHON), reason="python tests are disabled in this environment")
def test_external_project_context_in_repl(
project_server: tuple[ProjectServer, int],
serena_config: SerenaConfig, # noqa: F811
monkeypatch: pytest.MonkeyPatch,
) -> None:
server, port = project_server
monkeypatch.setattr(ProjectServer, "PORT", port) # let the REPL's external project context use the test server
# enable the optional "ext" facade
serena_config.included_apis = ["ext"]
# the querying agent has another project active and queries the python test project
serena_config.auth_secret = server.get_auth_secret()
agent = SerenaAgent(project="test_repo_typescript", serena_config=serena_config)
agent.execute_task(lambda: None)
try:
tool = agent.get_tool(SerenaReplTool)
session_id = agent.create_session().session_id
code = (
'with s.ext.read_project_context("test_repo_python"):\n'
' result = s.lsp.find_symbol("create_user")\n'
"[s.name for s in result.symbols]"
)
assert tool.apply(session_id, code) == "create_user"
assert server._loaded_projects_by_root, "the operation was not executed by the project server"
# the result persists and can be used after the context; the active project is restored
assert "services.py" in tool.apply(session_id, "result.represent()")
assert "test_repo_typescript" in agent.get_current_config_overview()
finally:
agent.on_shutdown(timeout=5)
+2 -4
View File
@@ -15,10 +15,8 @@ def read_file_tool(tmp_path: Path) -> ReadFileTool:
project = Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False)) project = Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False))
agent = MagicMock() agent = MagicMock()
agent.get_active_project_or_raise.return_value = project agent.get_active_project_or_raise.return_value = project
tool = ReadFileTool(agent) agent.serena_config.default_max_tool_answer_chars = 10000
# bypass the length limit, which would otherwise depend on the agent configuration return ReadFileTool(agent)
tool._limit_length = lambda result, max_answer_chars: result
return tool
def _deleted_by_delete_lines(content: str, line: int) -> str: def _deleted_by_delete_lines(content: str, line: int) -> str:
+81
View File
@@ -0,0 +1,81 @@
"""
Tests for the file system facade API.
"""
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from serena.config.serena_config import SerenaConfig
from serena.project import Project
from serena.repl.api.fs_api import FsApi
from serena.repl.facade import ApiScope, Facade
@pytest.fixture
def project(tmp_path: Path) -> Project:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "a.py").write_text("x = foo(1)\ny = foo(2)\nz = 3\n", encoding="utf-8")
(tmp_path / "src" / "b.txt").write_text("foo in text\n", encoding="utf-8")
(tmp_path / "README.md").write_text("# readme\n", encoding="utf-8")
return Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False))
@pytest.fixture
def api(project: Project) -> FsApi:
agent = MagicMock()
agent.get_active_project_or_raise.return_value = project
agent.serena_config.default_max_tool_answer_chars = 10000
return FsApi(agent)
def test_facade_exposes_file_operations(api: FsApi) -> None:
facade = Facade.from_api(api, ApiScope())
assert facade.name == "fs"
assert set(facade.enabled_method_names) == {"read_file", "create_text_file", "list_dir", "find_file", "search_for_pattern"}
assert {name for name in facade.enabled_method_names if facade.get_method(name).info.can_edit} == {"create_text_file"}
def test_read_file(api: FsApi) -> None:
content = api.read_file("src/a.py")
assert content.lines == ["x = foo(1)", "y = foo(2)", "z = 3", ""]
assert content.represent() == content.text
assert api.read_file("src/a.py", start_line=1, end_line=1).text == "y = foo(2)"
assert api.read_file("src/a.py", start_line=-2).lines == ["z = 3", ""]
def test_create_text_file(api: FsApi, project: Project) -> None:
result = api.create_text_file("sub/new.txt", "hello\n")
assert "new.txt" in result
assert (Path(project.project_root) / "sub" / "new.txt").read_text(encoding="utf-8") == "hello\n"
result = api.create_text_file("sub/new.txt", "changed\n")
assert "Overwrote" in result
with pytest.raises(AssertionError):
api.create_text_file("../outside.txt", "nope")
def test_list_dir_and_find_file(api: FsApi) -> None:
listing = api.list_dir(".", recursive=True)
assert "src" in listing.dirs
assert {"src/a.py", "src/b.txt", "README.md"} <= {f.replace("\\", "/") for f in listing.files}
assert '"dirs"' in listing.represent() and '"files"' in listing.represent()
with pytest.raises(FileNotFoundError):
api.list_dir("missing", recursive=False)
assert [f.replace("\\", "/") for f in api.find_file("*.py", ".")] == ["src/a.py"]
def test_search_for_pattern(api: FsApi) -> None:
matches = api.search_for_pattern("foo", relative_path="src")
assert len(matches) == 3
assert {m.source_file_path.replace("\\", "/") for m in matches.matches} == {"src/a.py", "src/b.txt"} # type: ignore
# restricting to code files excludes the text file; the rendering maps files to matched lines
code_matches = api.search_for_pattern("foo", restrict_search_to_code_files=True)
assert all(m.source_file_path.endswith("a.py") for m in code_matches.matches) # type: ignore
assert "foo(1)" in code_matches.represent()
+79
View File
@@ -0,0 +1,79 @@
"""
Tests for the JetBrains facade API, using a mocked plugin client (no IDE required).
"""
from unittest.mock import MagicMock, patch
import pytest
from serena.repl.api.jb_api import JetBrainsApi
from serena.repl.facade import ApiScope, Facade
@pytest.fixture
def agent() -> MagicMock:
agent = MagicMock()
agent.serena_config.default_max_tool_answer_chars = 10000
return agent
@pytest.fixture
def client() -> MagicMock:
client = MagicMock()
with patch("serena.repl.api.jb_api.JetBrainsPluginClient.from_project") as from_project:
from_project.return_value.__enter__.return_value = client
yield client
def test_facade_exposes_all_jetbrains_operations(agent: MagicMock) -> None:
facade = Facade.from_api(JetBrainsApi(agent), ApiScope())
assert facade.name == "jb"
assert set(facade.enabled_method_names) == {
"find_symbol",
"find_referencing_symbols",
"get_symbols_overview",
"get_type_hierarchy",
"find_declaration",
"find_implementations",
"rename",
"move",
"safe_delete",
"inline_symbol",
"run_inspections",
"list_inspections",
"debug_eval",
"debug_eval_info",
}
def test_find_symbol_renders_grouped_and_exposes_symbols(agent: MagicMock, client: MagicMock) -> None:
symbols = [
{"name_path": "Foo", "type": "class", "relative_path": "a.py", "quick_info": "class Foo"},
{"name_path": "Bar/foo", "type": "method", "relative_path": "b.py", "quick_info": "def foo()"},
]
client.find_symbol.return_value = {"symbols": symbols}
result = JetBrainsApi(agent).find_symbol("foo")
# the underlying symbols are accessible from code
assert [s["name_path"] for s in result.symbols] == ["Foo", "Bar/foo"]
# and the rendering contains them, grouped by file
rendered = result.represent()
assert '"a.py"' in rendered and '"b.py"' in rendered
assert "class Foo" in rendered
def test_find_symbol_rejects_too_many_matches_with_identifiers(agent: MagicMock, client: MagicMock) -> None:
client.find_symbol.return_value = {
"symbols": [{"name_path": f"foo{i}", "type": "function", "relative_path": "a.py", "body": "..."} for i in range(3)]
}
with pytest.raises(ValueError, match="Matched 3>max_matches=1") as exc_info:
JetBrainsApi(agent).find_symbol("foo*", max_matches=1)
assert "foo2" in str(exc_info.value)
assert '"body"' not in str(exc_info.value) # only identifiers, no content
def test_find_symbol_rejects_wildcard_only_pattern(agent: MagicMock, client: MagicMock) -> None:
with pytest.raises(ValueError, match="get_symbols_overview"):
JetBrainsApi(agent).find_symbol("*")
client.find_symbol.assert_not_called()
+5 -2
View File
@@ -62,7 +62,8 @@ class FileSystemSyncTestCase:
symbol_names = [ref["name_path"].split("/")[-1] for ref in ref_symbols] symbol_names = [ref["name_path"].split("/")[-1] for ref in ref_symbols]
return symbol_names return symbol_names
else: else:
ls = next(iter(agent.get_active_project_or_raise().language_server_manager.iter_language_servers())) ls_manager = agent.get_active_project_or_raise().get_language_server_manager_or_raise()
ls = next(iter(ls_manager.iter_language_servers()))
document_symbols = ls.request_document_symbols(self._TARGET_FILE).get_all_symbols_and_roots() document_symbols = ls.request_document_symbols(self._TARGET_FILE).get_all_symbols_and_roots()
target = next((s for s in document_symbols[0] if s.get("name") == self._TARGET_SYMBOL), None) target = next((s for s in document_symbols[0] if s.get("name") == self._TARGET_SYMBOL), None)
assert target is not None and "selectionRange" in target, f"{self._TARGET_SYMBOL} not found in {self._TARGET_FILE}" assert target is not None and "selectionRange" in target, f"{self._TARGET_SYMBOL} not found in {self._TARGET_FILE}"
@@ -162,7 +163,9 @@ class SymbolPositionStaleAfterExternalEditTestCase:
with agent_for_project_context(LanguageServerId.PYTHON, str(repo_root)) as agent: with agent_for_project_context(LanguageServerId.PYTHON, str(repo_root)) as agent:
project = agent.get_active_project_or_raise() project = agent.get_active_project_or_raise()
ls = next(iter(project.language_server_manager.iter_language_servers())) ls_manager = project.language_server_manager
assert ls_manager is not None
ls = next(iter(ls_manager.iter_language_servers()))
tool = agent.get_tool(FindSymbolTool) tool = agent.get_tool(FindSymbolTool)
# Hold the file's buffer open across the external edit, mirroring the state left # Hold the file's buffer open across the external edit, mirroring the state left
+13 -21
View File
@@ -1,13 +1,14 @@
"""Tests for the mcp.py module in serena.""" """Tests for the mcp.py module in serena."""
import pytest import pytest
from mcp.server.fastmcp.tools.base import Tool as MCPTool from mcp.server.mcpserver import Context
from mcp.server.mcpserver.tools.base import Tool as MCPTool
from serena import __version__
from serena.agent import Tool, ToolRegistry from serena.agent import Tool, ToolRegistry
from serena.config.context_mode import SerenaAgentContext from serena.config.context_mode import SerenaAgentContext
from serena.config.serena_config import SerenaConfig
from serena.mcp import SerenaMCPFactory from serena.mcp import SerenaMCPFactory
from serena.repl.facade import ApiScope
from serena.repl.repl import SerenaRepl
make_tool = SerenaMCPFactory.make_mcp_tool make_tool = SerenaMCPFactory.make_mcp_tool
@@ -22,6 +23,14 @@ class MockAgent:
def get_context() -> SerenaAgentContext: def get_context() -> SerenaAgentContext:
return SerenaAgentContext.load_default() return SerenaAgentContext.load_default()
@staticmethod
def get_repl() -> SerenaRepl:
return SerenaRepl([], ApiScope())
@staticmethod
def is_single_project() -> bool:
return False
class BaseMockTool(Tool): class BaseMockTool(Tool):
"""A mock Tool class for testing.""" """A mock Tool class for testing."""
@@ -46,30 +55,13 @@ class BasicTool(BaseMockTool):
self, self,
log_call: bool = True, log_call: bool = True,
catch_exceptions: bool = True, catch_exceptions: bool = True,
mcp_ctx: Context | None = None,
**kwargs, **kwargs,
) -> str: ) -> str:
"""Mock implementation of apply_ex.""" """Mock implementation of apply_ex."""
return self.apply(**kwargs) return self.apply(**kwargs)
def test_create_mcp_server_reports_serena_version(monkeypatch: pytest.MonkeyPatch) -> None:
"""MCP initialize must report Serena's version, not the installed mcp SDK version."""
class MinimalAgent:
def create_connection_prompt(self) -> str:
return ""
monkeypatch.setattr(SerenaConfig, "from_config_file", classmethod(lambda cls: SerenaConfig()))
factory = SerenaMCPFactory(transport="stdio")
monkeypatch.setattr(factory, "_create_serena_agent", lambda *args, **kwargs: MinimalAgent())
mcp_server = factory.create_mcp_server()
initialization_options = mcp_server._mcp_server.create_initialization_options()
assert initialization_options.server_name == "Serena"
assert initialization_options.server_version == __version__
def test_make_tool_basic() -> None: def test_make_tool_basic() -> None:
"""Test that make_tool correctly creates an MCP tool from a Tool object.""" """Test that make_tool correctly creates an MCP tool from a Tool object."""
mock_tool = BasicTool() mock_tool = BasicTool()
+68
View File
@@ -0,0 +1,68 @@
"""
Tests for the memory facade API.
"""
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from serena.config.serena_config import SerenaConfig
from serena.project import Project
from serena.repl.api.mem_api import MemoryApi
from serena.repl.facade import ApiScope, Facade
@pytest.fixture
def api(tmp_path: Path) -> MemoryApi:
project = Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False))
agent = MagicMock()
agent.get_active_project_or_raise.return_value = project
agent.serena_config.default_max_tool_answer_chars = 10000
return MemoryApi(agent)
def test_facade_exposes_memory_operations(api: MemoryApi) -> None:
facade = Facade.from_api(api, ApiScope())
assert facade.name == "mem"
assert set(facade.enabled_method_names) == {
"list_memories",
"read_memory",
"write_memory",
"edit_memory",
"rename_memory",
"delete_memory",
"onboarding",
}
assert {name for name in facade.enabled_method_names if facade.get_method(name).info.can_edit} == {
"write_memory",
"edit_memory",
"rename_memory",
"delete_memory",
}
def test_memory_lifecycle(api: MemoryApi) -> None:
api.write_memory("topic/first", "# First\nhello")
api.write_memory("second", "see `mem:topic/first`")
# listing exposes the names to code and renders as JSON (global memories of the machine may be present, too)
memory_list = api.list_memories()
assert {"second", "topic/first"} <= set(memory_list.memories)
assert api.list_memories("topic").memories == ["topic/first"]
assert '"memories"' in memory_list.represent()
# reading, editing, renaming (with reference propagation) and deleting
assert api.read_memory("topic/first") == "# First\nhello"
api.edit_memory("topic/first", "hello", "world", mode="literal")
assert api.read_memory("topic/first") == "# First\nworld"
api.rename_memory("topic/first", "topic/renamed")
assert "mem:topic/renamed" in api.read_memory("second")
api.delete_memory("second")
assert "second" not in api.list_memories().memories
assert api.list_memories("topic").memories == ["topic/renamed"]
def test_write_memory_rejects_overlong_content(api: MemoryApi) -> None:
with pytest.raises(ValueError, match="too long"):
api.write_memory("big", "x" * 100, max_chars=10)
+38
View File
@@ -844,3 +844,41 @@ class TestAutoPrefixBareReferences:
# idempotent: the second run should not touch anything # idempotent: the second run should not touch anything
assert second.total_replacements == 0 assert second.total_replacements == 0
assert fs_manager.load_memory("docs") == "the mem:auth/login process" assert fs_manager.load_memory("docs") == "the mem:auth/login process"
class TestRenameMemorySparesReadOnlyMemories:
"""Regression: a tool-context rename enumerated read-only memories, so propagating the
reference into one raised ``PermissionError`` after the rename itself had already been applied.
"""
@staticmethod
def _manager(tmp_path, monkeypatch) -> MemoryManager:
manager = MemoryManager(serena_data_folder=tmp_path, read_only_memory_patterns=[r"frozen/.*"])
# the global memories of the machine would otherwise join the enumeration as well
global_dir = tmp_path / "global"
global_dir.mkdir()
monkeypatch.setattr(manager, "_global_memory_dir", global_dir)
_write(manager, "auth/login", "# login notes")
_write(manager, "frozen/notes", "see `mem:auth/login`")
_write(manager, "docs", "first `mem:auth/login`, then `mem:auth/login`")
return manager
def test_tool_context_rename_completes_and_leaves_read_only_reference_alone(self, tmp_path, monkeypatch) -> None:
manager = self._manager(tmp_path, monkeypatch)
message, n_updated = manager.rename_memory_and_propagate_references("auth/login", "auth/signin", is_tool_context=True)
assert "auth/signin" in message
assert manager.load_memory("auth/signin") == "# login notes"
assert manager.load_memory("docs") == "first `mem:auth/signin`, then `mem:auth/signin`"
assert manager.load_memory("frozen/notes") == "see `mem:auth/login`"
assert n_updated == 2
def test_cli_context_rename_still_propagates_into_read_only_memories(self, tmp_path, monkeypatch) -> None:
manager = self._manager(tmp_path, monkeypatch)
_, n_updated = manager.rename_memory_and_propagate_references("auth/login", "auth/signin", is_tool_context=False)
assert manager.load_memory("frozen/notes") == "see `mem:auth/signin`"
assert manager.load_memory("docs") == "first `mem:auth/signin`, then `mem:auth/signin`"
assert n_updated == 3
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more