` from `type_hierarchy`). Direct search with `search_deps=True` returned empty results for `numpy.array` and `torch.Tensor`. The JetBrains IDE indexing is limited to what the language server has resolved.
+
+Built-in: Can `Read` site-packages files if you know the path, but discovery is manual.
+
+**Verdict:** Minor Serena advantage — external symbols are accessible through tool chains but not through direct search. Neither toolset makes this easy.
+
+### 3.2 Single-File Edits
+
+#### Task 7a: Small tweak (1-line change in 22-line method)
+
+| Metric | Edit | Serena `replace_symbol_body` |
+|--------|------|------------------------------|
+| Prerequisite | 1 Read (6 lines of context) | 1 `find_symbol` (gets full body) |
+| Payload sent | ~200 chars (old + new string) | ~800 chars (full method body) |
+| Payload received | Success message | "OK" |
+| Total payload | ~200 chars edit + ~300 chars read = ~500 | ~800 chars edit + ~800 chars read = ~1600 |
+
+**Verdict:** Edit is 3x more token-efficient for small tweaks inside methods.
+
+#### Task 7b: Medium rewrite (~6 line changes in 20-line method)
+
+| Metric | Edit | Serena `replace_symbol_body` |
+|--------|------|------------------------------|
+| Payload sent | ~700 chars (old + new, full method) | ~500 chars (new body only) |
+| Prerequisite read | ~500 chars | ~500 chars (from prior `find_symbol`) |
+
+**Verdict:** Roughly equal. For medium rewrites, payloads converge.
+
+#### Task 7c: Large rewrite (full body of 55+ line method)
+
+For a full-body rewrite of a 55-line method:
+- Edit: old (~55 lines) + new (~55 lines) = ~110 lines sent
+- Serena: new body only (~55 lines) sent
+
+**Verdict:** Serena is ~2x more token-efficient for full-body rewrites. The advantage grows linearly with method size.
+
+#### Task 8: Insert a new function after an existing one
+
+| Step | Serena | Built-in |
+|------|--------|----------|
+| 1 | `find_symbol("refresh_all_sequence_stats")` to confirm target | `Read(offset=250, limit=10)` to find insertion point |
+| 2 | `insert_after_symbol(name_path, body)` | `Edit(old_string=anchor, new_string=anchor+new_fn)` |
+| Total calls | 2 | 2 |
+| Payload sent | New function body only (~300 chars) | Anchor context + new function (~400 chars) |
+
+**Verdict:** No meaningful difference. Both require 2 calls and produce identical results.
+
+#### Task 9: Rename a private helper (single-file, 4 occurrences)
+
+| Metric | Serena `rename` | Edit `replace_all` |
+|--------|-----------------|-------------------|
+| Calls | 1 | 1 |
+| Prerequisites | None | Must have Read the file first |
+| Result | All 4 occurrences renamed | All 4 occurrences renamed |
+
+**Verdict:** Functionally identical. Both are 1 call. Edit's Read-first requirement is usually satisfied from prior exploration.
+
+### 3.3 Multi-File Changes
+
+#### Task 10: Cross-file rename (`CollectStatsBase` → `BaseCollectStats`, 4 files, 10 occurrences)
+
+| Step | Serena | Built-in |
+|------|--------|----------|
+| Find references | Automatic | 1 Grep call |
+| Read files | Not needed | 3 Read calls (Edit's prerequisite) |
+| Apply edits | 1 rename call (atomic) | 4 Edit calls (one per file) |
+| Verify | Return: "Success" | Manual (4 success messages) |
+| **Total calls** | **1** | **8** (1 grep + 3 reads + 4 edits) |
+
+**Verdict:** Serena converts an 8-call manual pipeline into 1 atomic operation. This is the single largest efficiency gain observed.
+
+#### Task 11: Move symbol to another module
+
+Serena's `move` tool:
+1. Moved `_nullable_slice` from `collector.py` to `converter.py`
+2. Added import in source file: `from tianshou.data.utils.converter import _nullable_slice`
+3. Added dependency import in target: `from tianshou.data.collector import _TArrLike`
+4. Removed definition from source
+
+Built-in equivalent: Read function body → Write to target → Edit source (remove definition + add import) → Edit target (add dependency import) = 5+ calls.
+
+**Issue:** The move created a circular import (source ↔ target). Serena does not detect or prevent this.
+
+**Verdict:** Serena automates the most tedious part (import management) but doesn't guard against circular dependencies. Saves 4+ calls at the cost of needing manual circular-import review.
+
+#### Task 12: Move file (`segtree.py` to parent directory)
+
+Serena's `move` tool:
+1. Moved the file
+2. Updated the one direct import in `__init__.py` (`tianshou.data.utils.segtree` → `tianshou.data.segtree`)
+3. Other files (`prio.py`, tests) imported via re-export and needed no changes
+
+Built-in equivalent: `git mv` + grep for old import path + edit each file = 3+ calls.
+
+**Verdict:** Serena saves 1–2 calls and automatically discovers which imports need updating.
+
+#### Task 12 (safe delete)
+
+Serena's `safe_delete` correctly refused to delete `_HACKY_create_info_batch` because it has a usage at line 730. The `propagate=true` mode (delete symbol + all call sites) failed for all tested symbols in this codebase.
+
+**Verdict:** The usage-check is valuable (saves you from deleting a used symbol). The propagation feature was non-functional for the tested Python symbols.
+
+#### Task 13: Inline
+
+Serena's `inline_symbol` failed for all tested symbols (`_nullable_slice`, `BaseCollector/env_num`). The tool appears to have limited Python support for inlining.
+
+**Verdict:** No successful inline demonstrated. Built-in manual inlining remains the only option.
+
+### 3.4 Reliability and Correctness
+
+#### Task 14: Scope precision
+
+Serena distinguishes `BaseCollector/_collect`, `Collector/_collect`, and `AsyncCollector/_collect` by name path. Grep for `def _collect` matches all three — manual filtering by class is required.
+
+**Verdict:** Serena's name-path addressing eliminates ambiguity that text search cannot resolve.
+
+#### Task 15: Atomicity
+
+Serena's cross-file rename is atomic: 4 files updated in 1 call, all-or-nothing. Built-in: 4 separate Edit calls — if call 3 fails, 2 files are updated and 2 are not.
+
+**Verdict:** Serena provides atomicity for cross-file operations. Built-in chains are inherently non-atomic.
+
+#### Task 16: Success signals
+
+Both return clear success/failure indicators. No meaningful difference.
+
+**Verdict:** Equal.
+
+### 3.5 Workflow Effects
+
+#### Task 17: Chain three edits in one file
+
+Edit with text matching: 3 sequential calls, no re-reads needed between them. Text anchors are immune to line-number shifts from prior edits.
+
+Serena `replace_symbol_body`: 3 sequential calls, no re-reads needed. Name-path addressing is also immune to line-number shifts.
+
+**Verdict:** No meaningful difference for chained single-file edits.
+
+#### Task 18: Multi-step exploration across edits
+
+Serena's name-path results from exploration remain valid after edits. Built-in line numbers go stale, but Edit uses text matching (not line numbers), so the practical impact is limited to `Read` calls that need updated offsets.
+
+**Verdict:** Minor Serena advantage. Name-path stability eliminates the need to re-scan after edits.
+
+### 3.6 Non-Interesting Tasks
+
+#### Task 19: Read non-code file
+
+Serena tools don't apply. `Read` is the correct tool.
+
+#### Task 20: Free-text pattern search
+
+Searching for `FIXME|HACK|TODO` across the codebase is a text search. Serena's semantic tools don't target this. `Grep` is the correct tool.
+
+**Verdict:** These tasks are firmly built-in territory. They represent an estimated 20–30% of daily coding work (reading configs, searching for strings, shell operations, git workflows).
+
+---
+
+## 4. Token-Efficiency Analysis
+
+### Payload differences across edit sizes
+
+| Edit type | Edit payload | Serena payload | Winner |
+|-----------|-------------|---------------|--------|
+| 1-line tweak in 22-line method | ~200 chars | ~800 chars | Edit (4x) |
+| 6-line change in 20-line method | ~700 chars | ~500 chars | Roughly equal |
+| Full rewrite of 55-line method | ~2200 chars | ~1100 chars | Serena (2x) |
+| Full rewrite of 330-line method | ~13,000 chars | ~6,500 chars | Serena (2x) |
+
+### Forced reads
+
+- Edit requires reading a file before editing it. This adds ~300–2000 chars per file.
+- Serena does not require reading before editing (name-path addressing).
+- For single-file edits where you already read the file, this is neutral.
+- For cross-file operations on files you haven't read, Serena saves 3–4 forced reads.
+
+### Stable vs ephemeral addressing
+
+- Serena: name paths (`Collector/_collect`) are stable across edits. Results from exploration remain valid.
+- Built-in: line numbers are ephemeral. `Read` results go stale after edits. `Edit` uses text matching, which is stable.
+- Practical impact: Low for one-shot edits, medium for edit-then-read-again workflows.
+
+**Verdict:** Edit wins for small tweaks (4x more token-efficient). Serena wins for full-body rewrites (2x more efficient) and cross-file operations (eliminates forced reads). The crossover point is approximately 50% of the method body changing — below that, Edit is more efficient; above that, Serena is.
+
+---
+
+## 5. Reliability and Correctness (Under Correct Use)
+
+### Precision of matching
+
+- Serena: Symbol-accurate. `find_referencing_symbols(CollectStats)` excludes `LoggedCollectStats`. No false positives observed.
+- Grep: Text-matched. `CollectStats` matches `LoggedCollectStats`, `CollectStatsBase`, and docstring references. Requires manual filtering.
+- Edit: Text-matched. `replace_all` replaces exact string matches. For unique symbol names, this is reliable. For common strings, it can over-match.
+
+### Scope disambiguation
+
+- Serena: `Collector/_collect` vs `AsyncCollector/_collect` — correctly distinguished by class-scoped name path.
+- Built-in: `def _collect` matches all implementations. Must manually verify class context.
+
+### Atomicity
+
+- Serena cross-file operations: Atomic. Single call, all-or-nothing.
+- Built-in multi-file chains: Non-atomic. Partial state possible if one call fails.
+
+### External dependency lookup
+
+- Serena: Can read external stubs (e.g., `abc.pyi`) through paths returned by other tools. Direct search (`search_deps=True`) returned empty for `torch.Tensor` and `numpy.array`. Limited to what JetBrains has indexed.
+- Built-in: Can `Read` site-packages files if path is known. No semantic indexing.
+
+**Verdict:** Serena provides strictly more precise semantic matching and atomic cross-file operations. External dependency lookup is limited in both toolsets.
+
+---
+
+## 6. Workflow Effects Across a Session
+
+### Where advantages compound
+
+1. **Explore → edit → re-explore cycle:** Serena's name-path results survive edits. In a long session making multiple changes, this saves re-scanning after each edit. The built-in's text matching also survives edits (Edit uses text, not line numbers), so the practical gap is smaller than it appears.
+
+2. **Cross-file refactoring chains:** Rename a class, then move it, then update all references — each Serena call is atomic and builds on the previous result. With built-ins, each step requires finding all sites, reading files, and editing — the manual equivalent of what Serena automates.
+
+### Where advantages diminish
+
+1. **Repeated small edits in one file:** Edit's text matching is equally stable and more token-efficient for small changes. No Serena advantage.
+
+2. **Exploration without editing:** Both toolsets provide usable results. Serena's are more structured but more verbose.
+
+3. **Non-Python files:** Serena's JetBrains backend provides no value for config files, shell scripts, markdown, or notebooks.
+
+**Verdict:** Serena's advantages compound in multi-step cross-file refactoring sessions. They do not compound for single-file iterative editing or non-code work.
+
+---
+
+## 7. Unique Capabilities
+
+1. **Atomic cross-file rename/move** — No built-in equivalent. The closest manual process is a grep-find-edit chain that is non-atomic and error-prone. Frequency: medium. Impact: high (eliminates partial-update risk).
+
+2. **Semantic reference finding with categorization** — `find_referencing_symbols` returns zero-false-positive results categorized by usage type (import, parameter, declaration, reference). Built-in Grep cannot distinguish these. Frequency: high. Impact: medium (saves manual filtering).
+
+3. **Type hierarchy traversal** — Returns transitive super/subtype chains including external library types in one call. Built-in requires iteration and cannot reach external deps. Frequency: medium. Impact: medium.
+
+4. **Symbol-scoped body retrieval** — Read a specific method by name path without reading the surrounding file. Built-in `Read` requires line-range knowledge. Frequency: high. Impact: low (both require 1 call, difference is stable vs ephemeral addressing).
+
+**Verdict:** Four unique capabilities, all semantic-code operations. The most impactful is atomic cross-file refactoring. None of these have practical built-in equivalents.
+
+---
+
+## 8. Tasks Outside Serena's Scope (Built-In Only)
+
+| Task | Tool | Frequency | Share of daily work |
+|------|------|-----------|-------------------|
+| Read config/TOML/yaml files | `Read` | High | |
+| Free-text search (log strings, TODOs, URLs) | `Grep` | High | |
+| File discovery by name pattern | `Glob` | Medium | |
+| Shell commands (git, pip, pytest) | `Bash` | High | |
+| Write new files from scratch | `Write` | Medium | |
+| Read images, notebooks, PDFs | `Read` | Low | |
+
+Estimated share of daily work covered by built-in-only tasks: **20–30%**. The remaining 70–80% involves reading, editing, and navigating code where Serena's semantic tools are applicable.
+
+**Verdict:** Serena covers the code-editing and code-navigation portions of a session. Config reading, text search, and shell operations remain built-in territory.
+
+---
+
+## 9. Practical Usage Rule
+
+**Use Serena for:** Any cross-file refactoring (rename, move), any "who uses this?" query, any type-hierarchy navigation, any full-method-body replacement, and any situation where you need symbol-accurate results without false positives.
+
+**Use built-ins for:** Small edits (1–3 lines) inside methods, free-text search, reading non-code files, file discovery, and shell operations.
+
+**Hybrid pattern (most efficient):** Use Serena to explore (overview, find symbol, find references) and Edit for small targeted changes. Use Serena for any cross-file refactoring. Use `Read`/`Grep`/`Glob` for non-code tasks and text search. This combination captures the strengths of both toolsets.
+
+**Verdict:** The optimal workflow uses Serena's semantic tools for code navigation and cross-file refactoring, and built-in Edit for small single-file changes. The two toolsets are complementary — Serena handles the structured code operations, built-ins handle the text and system operations.
+
+---
+
+## Appendix: Call Count Summary
+
+| Task | Serena calls | Built-in calls | Delta |
+|------|-------------|----------------|-------|
+| Structural overview (1 file) | 1 | 1 | 0 |
+| Method body retrieval | 1 | 1 | 0 |
+| Find references (1 symbol) | 1 | 1 | 0 (but Serena has 0 false positives vs Grep's 5+) |
+| Type hierarchy | 1 | 2–3 | −1 to −2 |
+| Small edit (1 line in 22-line method) | 1 (+1 read) | 1 (+1 read) | 0 |
+| Medium edit (6 lines in 20-line method) | 1 | 1 | 0 |
+| Insert new method | 1 (+1 confirm) | 1 (+1 read) | 0 |
+| Single-file rename (4 occurrences) | 1 | 1 | 0 |
+| **Cross-file rename (4 files, 10 occurrences)** | **1** | **8** | **−7** |
+| **Move symbol to another module** | **1** | **5+** | **−4+** |
+| **Move file + update imports** | **1** | **3+** | **−2+** |
+| Safe delete (usage check) | 1 | 1 grep | 0 |
+
+Largest single-task delta: cross-file rename saves 7 calls and provides atomicity.
diff --git a/docs/04-evaluation/030_results/050_junie_plugin_on_tianshou.md b/docs/04-evaluation/030_results/050_junie_plugin_on_tianshou.md
new file mode 100644
index 00000000..cfafc462
--- /dev/null
+++ b/docs/04-evaluation/030_results/050_junie_plugin_on_tianshou.md
@@ -0,0 +1,345 @@
+:::{admonition} Evaluation Result
+:class: note
+**Generated by**: Claude Opus 4.6 (coding AI agent in JetBrains Junie Plugin)
+**Codebase:** [Tianshou](https://github.com/thu-ml/tianshou) — a Python reinforcement learning library (~26K lines, 43 source files)
+**Date**: 2026-04-17
+:::
+
+# JetBrains Junie Plugin (Opus 4.6)
+
+> Serena gives me what my built-in tools can't — the ability to move a function between modules with all imports updated atomically, trace a class hierarchy into dependencies, and safely delete symbols with usage guards — and I'd ask my owner to add it for the move-refactoring and semantic navigation capabilities alone.
+
+---
+
+## 1. Headline: What Serena Changes
+
+Serena adds three categories of capability on top of built-in tools:
+
+**(a) Tasks where Serena adds capability:**
+- **Move symbol between modules** (with automatic import updates) — no built-in equivalent.
+- **Move file/package** (with automatic import updates) — no built-in equivalent.
+- **Type hierarchy traversal** — transitive supertype/subtype chains in 1 call, including into external dependencies. No built-in equivalent.
+- **External dependency symbol lookup** — search and inspect symbols in installed packages (site-packages) without manual path discovery. Built-ins cannot do this.
+- **Safe delete with usage checking** — refuses deletion if usages exist, reports exact locations. Built-ins require a manual search-then-decide workflow.
+- **Propagated delete** — delete a symbol and automatically remove all call sites. No built-in equivalent.
+- **Inline symbol** — replace all call sites with the function body. No built-in equivalent.
+- **Scope-precise symbol targeting** — name paths like `AsyncCollector/_collect` disambiguate overrides that text search cannot distinguish.
+
+**(b) Tasks where Serena applies but offers no meaningful improvement:**
+- **Cross-file rename** — both Serena (`rename`) and built-in (`rename_element`) perform semantic, cross-file renames in 1 call. Functionally equivalent.
+- **Single-file rename** — same as above; both are 1-call semantic renames.
+- **Structural overview of a single file** — both toolsets produce comparable results in 1 call.
+- **Small edits (1-3 lines)** — Serena's `replace_symbol_body` must send the entire method body; built-in `search_replace` sends only the changed line. Built-in is more token-efficient here.
+
+**(c) Tasks outside Serena's scope (built-in only):**
+- Reading non-code files (configs, docs, changelogs)
+- Free-text search across the repo (log strings, URLs, magic constants)
+- Terminal commands, test execution, git operations
+- Creating new files from scratch
+- Repository-level directory listing
+
+**Verdict:**
+Serena's primary contribution is move-refactoring (symbol and file moves with import updates), semantic code navigation (type hierarchy, dependency lookup), and safe/propagated delete — capabilities with no built-in equivalent.
+
+---
+
+## 2. Added Value and Differences by Area
+
+- **Move symbol/file with import updates (positive).** Frequency: occasional in refactoring work. Value per hit: saves ~7 calls and eliminates manual import tracking. Moving `get_stddev_from_dist` between modules: 1 Serena call vs. ~7 built-in calls. No built-in equivalent.
+
+- **Type hierarchy and reference finding (positive).** Frequency: a few times per exploration session. Value per hit: saves 2-4 search-and-read cycles and provides information (transitive supertypes, external dependency chains) that built-ins cannot produce at all. `BaseCollector`'s full hierarchy (ABC→object up, Collector→AsyncCollector down) returned in 1 call.
+
+- **Targeted symbol retrieval by name path (positive).** Frequency: many times per session. Value per hit: saves 1 prerequisite read (no line-number lookup needed). `find_symbol` with `include_body=true` returns exact method body in 1 call vs. 2 calls (structure + open).
+
+- **Safe delete with usage guard (positive).** Frequency: occasional. Value per hit: eliminates risk of orphaned references. Serena refused to delete `_dict_of_arr_to_arr_of_dicts` and reported 2 exact usages. Built-in: requires manual search first.
+
+- **Cross-file rename (neutral).** Frequency: several times per session. Value per hit: zero — both Serena's `rename` and built-in `rename_element` perform semantic cross-file renames in 1 call with equivalent results.
+
+- **Single-file edits (neutral to slightly negative for small edits).** Frequency: very high. Value per hit: for small edits, Serena's `replace_symbol_body` sends more tokens (entire method body) than `search_replace` (just the changed lines). For full method rewrites, comparable. For inserts, Serena's `insert_after_symbol` saves 1 prerequisite read.
+
+- **Structural overview (neutral).** Frequency: a few times per session. Value per hit: both toolsets return comparable information in 1 call.
+
+**Verdict:** Serena's value concentrates in move-refactoring and semantic navigation; rename is matched by the built-in `rename_element`.
+
+---
+
+## 3. Detailed Evidence, Grouped by Capability
+
+### 3.1 Structural Overview (Task 2)
+
+**Attempted:** Get structural overview of `collector.py` (1552 lines, 15+ classes).
+
+| Axis | Serena (`get_symbols_overview`) | Built-in (`get_file_structure`) |
+|------|------|------|
+| Calls | 1 | 1 |
+| Output | JSON tree: class names, method names, field names | Flat list: class/method names with line ranges and signatures |
+| Unique info | Field/attribute names | Line numbers, full parameter signatures |
+| Follow-up to read a method | `find_symbol` by name path (1 call) | `open` at line number (1 call) |
+
+**Verdict:** Functionally equivalent; each includes information the other omits. No meaningful delta.
+
+### 3.2 Targeted Symbol Retrieval (Task 3)
+
+**Attempted:** Retrieve body of `CollectStats/refresh_all_sequence_stats` without reading surrounding file.
+
+| Axis | Serena | Built-in |
+|------|------|------|
+| Calls | 1 (`find_symbol` with `include_body`) | 2 (`get_file_structure` + `open`) |
+| Prerequisite | None (name path is stable) | Must know line number |
+| Output payload | 4 lines (exact method body) | 100 lines (open window) |
+
+**Verdict:** Serena saves 1 call and returns a smaller, more precise payload. Minor but consistent advantage.
+
+### 3.3 Reference Finding (Task 4)
+
+**Attempted:** Find all references to `CollectStats` across the codebase.
+
+| Axis | Serena (`find_referencing_symbols`) | Built-in (`search_project`) |
+|------|------|------|
+| Calls | 1 | 1 |
+| Output | Summarized narrative: grouped by file, annotated with context (imports, instantiations, type hints) | Raw list: 100+ text matches, includes docs, comments, strings |
+| Precision | Code references only | All text mentions |
+| Recall | Code references only | Everything including non-code |
+
+**Verdict:** Serena provides higher precision (code-only references with semantic context). Built-in provides higher recall (includes docs, comments). Different tools for different questions.
+
+### 3.4 Type Hierarchy (Task 5)
+
+**Attempted:** Full type hierarchy of `BaseCollector` — supertypes and subtypes, transitively.
+
+| Axis | Serena (`type_hierarchy`) | Built-in |
+|------|------|------|
+| Calls | 1 | 3+ (grep for class declarations, read each, trace manually) |
+| Result | `BaseCollector → ABC → object` (up), `→ Collector → AsyncCollector` (down) | Partial: can find direct subclasses by grep, but supertypes of supertypes require reading external files |
+| External deps | Included (ABC from `abc` module, object from `builtins`) | Not accessible |
+
+**Verdict:** Unique capability. No practical built-in equivalent for transitive hierarchy, especially into dependencies.
+
+### 3.5 External Dependency Lookup (Task 6)
+
+**Attempted:** Find `Distribution` class from `torch.distributions`.
+
+| Axis | Serena (`find_symbol` with `search_deps`) | Built-in |
+|------|------|------|
+| Calls | 1 | Not possible without manual site-packages navigation |
+| Result | Found 41 `Distribution` classes across all installed packages with ext-identifiers | N/A |
+| Follow-up | Can use ext-identifier to read body/info | Would need to find virtualenv path, navigate to package, read file |
+
+**Verdict:** Unique capability. Built-ins have no access to dependency source code without manual path discovery.
+
+### 3.6 Small Edit — 1 Line Change (Task 7a)
+
+**Attempted:** Change error message in `_validate_buffer` (21-line method).
+
+| Axis | Serena | Built-in |
+|------|------|------|
+| Calls | 2 (`find_symbol` + `replace_symbol_body`) | 1 (`search_replace`) |
+| Input payload | ~21 lines (full method body) | ~1 line (search) + ~1 line (replace) |
+| Prerequisite reads | 1 (find_symbol to get current body) | 0 (if search string is known) |
+
+**Verdict:** Built-in is more efficient for small, targeted edits. Serena's method-granularity addressing forces sending the entire body.
+
+### 3.7 Medium Rewrite — ~20 Lines (Task 7b)
+
+**Attempted:** Rewrite `update_at_step_batch` (20 lines), changing variable names and adding logging.
+
+| Axis | Serena | Built-in |
+|------|------|------|
+| Calls | 2 (`find_symbol` + `replace_symbol_body`) | 1 (`search_replace` with old body → new body) |
+| Input payload | ~20 lines (new body) | ~40 lines (old + new body) |
+| Prerequisite reads | 1 (find_symbol) | 1 (open to see current code) |
+
+**Verdict:** Comparable. Serena sends less payload (new body only vs. old+new), but requires a prerequisite find_symbol call.
+
+### 3.8 Insert New Method (Task 8)
+
+**Attempted:** Insert `summary_string` method after `refresh_all_sequence_stats` in `CollectStats`.
+
+| Axis | Serena (`insert_after_symbol`) | Built-in (`search_replace`) |
+|------|------|------|
+| Calls | 1 | 1-2 (need to find anchor text, then insert) |
+| Addressing | By name path (stable) | By text anchor or line number (fragile) |
+| Prerequisite | None | May need `open` to find insertion point |
+
+**Verdict:** Serena's stable addressing is a minor advantage — eliminates the need to find an anchor.
+
+### 3.9 Single-File Rename (Task 9)
+
+**Attempted:** Rename `_nullable_slice` → `_slice_if_not_none` (5 occurrences in 1 file).
+
+| Axis | Serena (`rename`) | Built-in (`rename_element`) |
+|------|------|------|
+| Calls | 1 | 1 |
+| Scope | Semantic (only code references) | Semantic (only code references) |
+
+**Verdict:** Functionally equivalent. Both perform semantic renames in 1 call.
+
+### 3.10 Cross-File Rename (Task 10)
+
+**Attempted:** Rename `CollectStatsBase` → `CollectStatsFoundation` (used in 4 files, 10 occurrences including imports and `__all__`).
+
+| Axis | Serena (`rename`) | Built-in (`rename_element`) |
+|------|------|------|
+| Calls | 1 | 1 |
+| Atomicity | All-or-nothing | All-or-nothing |
+| Import handling | Automatic | Automatic |
+| Result | 4 files, 10 replacements | 4 files, 10 replacements |
+
+**Verdict:** No delta. Both toolsets provide atomic, semantic cross-file rename in a single call.
+
+### 3.11 Move Symbol Between Modules (Task 11)
+
+**Attempted:** Move `get_stddev_from_dist` from `collector.py` to `stats.py`.
+
+| Axis | Serena (`move`) | Built-in |
+|------|------|------|
+| Calls | 1 | ~7 (read source, copy to target, delete from source, find imports, update each import, verify) |
+| Files modified | 3 (source, target, test file) | Same 3, but manually |
+| Import updates | Automatic | Manual |
+| Atomicity | Atomic | Non-atomic |
+
+**Verdict:** Unique capability at this level of automation. No practical built-in equivalent at comparable effort. This is now the highest-value delta in the evaluation.
+
+### 3.12 Move File (Task 12)
+
+**Attempted:** Move `segtree.py` from `tianshou/data/utils/` to `tianshou/utils/`.
+
+| Axis | Serena (`move`) | Built-in |
+|------|------|------|
+| Calls | 1 | 3+ (bash mv, find imports, update each) |
+| Import updates | Automatic (updated `__init__.py`) | Manual |
+
+**Verdict:** Serena advantage — single call with automatic import updates.
+
+### 3.13 Safe Delete (Task 13)
+
+**Attempted:** Delete `_dict_of_arr_to_arr_of_dicts` (has 2 usages).
+
+| Axis | Serena (`safe_delete`) | Built-in |
+|------|------|------|
+| Calls | 1 (refused, reported usages) | 2 (search_project + manual decision) |
+| Safety | Refuses deletion, reports exact usages with file:line and enclosing function | Must manually verify search results |
+
+**Verdict:** Serena provides a safety guard with precise diagnostics. Minor but useful advantage.
+
+### 3.14 Scope Precision (Task 14)
+
+**Attempted:** Target `AsyncCollector/_collect` specifically (3 classes define `_collect`).
+
+Serena's name path `AsyncCollector/_collect` unambiguously selects the override. A text search for `_collect` matches all three definitions plus dozens of call sites. Built-in `get_file_structure` can disambiguate by line number, but that requires a prerequisite read and the line number goes stale after edits.
+
+**Verdict:** Serena's name-path addressing provides persistent, unambiguous symbol targeting. Meaningful advantage when multiple overrides exist.
+
+### 3.15 Chained Edits (Task 17)
+
+**Attempted:** Three sequential `replace_symbol_body` calls on different methods in the same file.
+
+All three succeeded without re-reading the file. Name paths remained stable across edits. Built-in `search_replace` also chains without re-reading (text anchors remain valid if edits are in different methods), but line numbers from `open` go stale after each edit.
+
+**Verdict:** Both toolsets chain well. Serena's name-path stability is a minor advantage over line-number-based addressing.
+
+---
+
+## 4. Token-Efficiency Analysis
+
+| Edit size | Serena payload | Built-in payload | Winner |
+|-----------|---------------|-----------------|--------|
+| Small (1-3 lines in 20-line method) | ~20 lines (full body) | ~2 lines (search+replace) | Built-in (10x less) |
+| Medium (rewrite ~20 lines) | ~20 lines (new body) + prerequisite find | ~40 lines (old+new) | Comparable |
+| Large (rewrite 50+ lines) | ~50+ lines (new body) | ~100+ lines (old+new) | Serena (2x less, no old body needed) |
+| Insert | ~N lines (new code only) | ~N lines + anchor context | Comparable |
+| Cross-file rename | ~1 line (name + new name) | ~1 line (name + new name) | Tie (both semantic) |
+| Move symbol | ~1 line (source + target) | ~7 calls with full bodies | Serena (>>10x less) |
+
+**Forced reads:** Serena's `replace_symbol_body` requires a `find_symbol` call to get the current body before editing. Built-in `search_replace` needs no prerequisite if the search text is known, but often requires `open` to discover it.
+
+**Stable vs. ephemeral addressing:** Serena's name paths survive edits; built-in line numbers do not. This matters in multi-edit sessions — Serena never needs to re-read for addressing purposes, while built-in may need to re-open after edits that shift line numbers.
+
+**Verdict:** For single-file small edits, built-ins are more token-efficient. For move-refactoring, Serena is substantially more efficient. For rename, both are equivalent. The crossover point for edits is roughly at method-level rewrites.
+
+---
+
+## 5. Reliability & Correctness (Under Correct Use)
+
+- **Precision of matching:** Serena matches by semantic identity (name path in the symbol tree). Built-in `search_replace` matches by text (can over-match). However, built-in `rename_element` also matches semantically. For rename operations, precision is equivalent.
+
+- **Scope disambiguation:** Serena's `AsyncCollector/_collect` is unambiguous. Built-in text search for `def _collect` returns 3 matches in collector.py alone. Disambiguation requires line numbers or surrounding context. Built-in `rename_element` also requires a file path and line number to disambiguate.
+
+- **Atomicity:** Both Serena's and built-in's rename operations are atomic. Serena's move operations are also atomic; built-ins have no equivalent move, so the comparison is moot. For sequential text edits (`search_replace`), partial failure is possible.
+
+- **Semantic queries vs. text search:** Serena's `find_referencing_symbols` returns only code references with semantic context. Built-in `search_project` returns all text matches. Neither is strictly better — they answer different questions.
+
+- **External dependency lookup:** Serena can search and inspect symbols in installed packages via `search_deps=true`. Built-ins have no access to dependency source without manual virtualenv/site-packages navigation. This depends on the IDE having indexed the project's interpreter.
+
+**Verdict:** For rename, correctness guarantees are equivalent between toolsets. Serena's unique correctness advantage is in move-refactoring atomicity and external dependency access.
+
+---
+
+## 6. Workflow Effects Across a Session
+
+- **Multi-edit stability:** Serena's name-path addressing remains valid across edits within a session. Built-in line numbers go stale after any edit that changes line counts. In a session with 10+ edits to the same file, Serena eliminates ~5-10 re-read calls that built-ins would need to refresh line numbers.
+
+- **Exploration → edit transition:** After exploring with Serena (find_symbol, type_hierarchy), the same name paths can be used directly for edits (replace_symbol_body, insert_after_symbol). Built-in exploration (get_file_structure) produces line numbers that must be used immediately before they go stale.
+
+- **Cross-file refactoring chains:** A rename is equivalent on both sides (1 call each). A move followed by a delete is 2 Serena calls vs. ~10+ built-in calls. The advantage compounds for move-heavy refactoring but not for rename-heavy refactoring.
+
+- **Session overhead:** Serena requires project activation (1 call at session start). The project can deactivate unexpectedly (observed once during this evaluation), requiring reactivation. This is a minor but real friction cost.
+
+**Verdict:** Serena's advantages compound for move-refactoring and exploration-heavy sessions; for rename-heavy sessions, both toolsets are equivalent.
+
+---
+
+## 7. Unique Capabilities (No Built-In Equivalent)
+
+| Capability | Frequency | Impact per use |
+|-----------|-----------|---------------|
+| Move symbol between modules (with import updates) | Occasional | Saves ~7 calls, eliminates manual import tracking |
+| Move file with import updates | Occasional | Saves ~3 calls |
+| Transitive type hierarchy | A few times per exploration session | Provides information unavailable to built-ins |
+| External dependency symbol lookup | Occasional | Provides information unavailable to built-ins |
+| Safe delete with usage guard | Occasional | Prevents accidental breakage |
+| Propagated delete (delete + remove call sites) | Rare | Saves multiple manual edits |
+| Inline symbol | Rare | Saves multiple manual edits |
+| Scope-precise symbol targeting by name path | Continuous (every symbol interaction) | Eliminates disambiguation overhead |
+
+**Verdict:** Serena provides 5-6 capabilities with no practical built-in equivalent, concentrated in move-refactoring, semantic navigation, and safe/propagated delete.
+
+---
+
+## 8. Tasks Outside Serena's Scope (Built-In Only)
+
+| Task | Share of typical session |
+|------|------------------------|
+| Reading/editing non-code files (configs, docs, changelogs, notebooks) | ~15-20% |
+| Free-text search (log strings, URLs, magic constants) | ~10% |
+| Terminal commands (git, test runners, build tools) | ~15-20% |
+| Creating new files from scratch | ~5-10% |
+| Directory listing and repo navigation | ~5% |
+| Running and debugging applications | ~10-15% |
+
+Estimated share of session where Serena's augmentation applies: **~35-50%** (code navigation, symbol editing, refactoring). The remainder is built-in-only territory.
+
+**Verdict:** Serena augments roughly a third to half of typical session work; the rest is inherently outside its scope and handled by built-ins.
+
+---
+
+## 9. Practical Usage Rule
+
+| Task type | Use |
+|-----------|-----|
+| Cross-file rename | Either (both provide semantic rename in 1 call) |
+| Move symbol/file with import updates | Serena (unique capability) |
+| Safe/propagated delete | Serena (unique capability) |
+| Inline symbol | Serena (unique capability) |
+| Type hierarchy, dependency lookup | Serena (unique capability) |
+| Find code references to a symbol | Serena (`find_referencing_symbols`) |
+| Find text mentions anywhere | Built-in (`search_project` / Grep) |
+| Small edit (1-3 lines) | Built-in (`search_replace`) |
+| Full method rewrite | Either (comparable) |
+| Insert at structural location | Serena (`insert_after_symbol`) if name path known; built-in otherwise |
+| Read non-code files | Built-in (`open` / Read) |
+| Terminal operations | Built-in (bash) |
+| New file creation | Built-in (`create`) |
+| Chained edits in one file | Either; Serena if line-number staleness is a concern |
+
+**Verdict:** Use Serena for move-refactoring, semantic navigation, and safe delete; use either toolset for rename; use built-ins for text-level edits, non-code files, and terminal operations.
diff --git a/docs/autogen_docs.py b/docs/autogen_docs.py
index 2043017c..43976fd9 100644
--- a/docs/autogen_docs.py
+++ b/docs/autogen_docs.py
@@ -207,6 +207,15 @@ def autogen_about_intro_features():
# remove callouts
about_text = re.subn(r"^> \[!\w+\]\s+(^>.*?$)+", "", about_text, flags=re.MULTILINE)[0]
+ # replace "Quick Demo" with "Video Introduction", removing the short video
+ about_text = about_text.replace("# Quick Demo", "# Video Introduction")
+ about_text = re.subn(r"^https://github.com/user-attachments/.*?$", "", about_text, flags=re.MULTILINE)[0]
+ about_text = about_text.replace(":tv: Longer video:", "Watch our video:")
+
+ # remove emojis (e.g. :tv:)
+ about_text = re.subn(r":\w+:\s+", "", about_text)[0]
+
+
f.write(f"{about_text}\n\n")
jetbrains_marketplace_link = ('```{raw} html\n'
diff --git a/flake.nix b/flake.nix
index e172a186..9550e4ff 100644
--- a/flake.nix
+++ b/flake.nix
@@ -100,6 +100,7 @@
} ''
mkdir -p $out/bin
ln -s ${packages.serena-env}/bin/serena $out/bin/serena
+ ln -s ${packages.serena-env}/bin/serena-hooks $out/bin/serena-hooks
'';
default = packages.serena;
};
diff --git a/pyproject.toml b/pyproject.toml
index e09bf469..df90518d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -88,13 +88,11 @@ dev = [
"sphinx_book_theme==1.1.4",
"sphinx-toolbox==3.7.0",
"jupyter-book==1.0.4.post1",
- "nbsphinx==0.9.7",
"pyinstrument==5.1.1",
"pytest-timeout==2.4.0",
# Transitive dev deps pinned for security (dependabot alerts).
# Exact pins because uvx installs from git, ignoring the lock file.
"tornado==6.5.5",
- "nbconvert==7.17.0",
"wheel==0.46.3",
"pyasn1==0.6.3",
"PyJWT==2.12.0",
diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py
index d382dc32..b0ed063b 100644
--- a/scripts/demo_run_tools.py
+++ b/scripts/demo_run_tools.py
@@ -7,8 +7,6 @@ import json
from pathlib import Path
from pprint import pprint
-from toon_format import encode
-
from serena.agent import SerenaAgent
from serena.config.serena_config import SerenaConfig
from serena.constants import REPO_ROOT
@@ -46,5 +44,5 @@ if __name__ == "__main__":
# keep_definition=True,
)
)
- pprint(encode(json.loads(result)))
+ pprint(json.loads(result))
# input("Press Enter to continue...")
diff --git a/src/serena/agent.py b/src/serena/agent.py
index efe4fc18..e3800b97 100644
--- a/src/serena/agent.py
+++ b/src/serena/agent.py
@@ -8,8 +8,10 @@ import os
import platform
import signal
import threading
+from collections import defaultdict
from collections.abc import Callable, Iterator, Sequence
from contextlib import contextmanager
+from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from logging import Logger
@@ -18,6 +20,7 @@ from typing import TYPE_CHECKING, Optional, TypeVar
import requests
import webview
from sensai.util import logging
+from sensai.util.helper import mark_used
from sensai.util.logging import LogTime
from sensai.util.string import dict_string
@@ -198,52 +201,125 @@ class ToolSet:
class ActiveModes:
+ _mode_instances: dict[str, SerenaAgentMode] = {}
+
def __init__(self) -> None:
- self._base_modes: Sequence[str] | None = None
- self._default_modes: Sequence[str] | None = None
- self._active_mode_names: Sequence[str] | None = []
- self._active_modes: Sequence[SerenaAgentMode] | None = []
+ self._configured_base_modes: Sequence[str] | None = None
+ self._configured_default_modes: Sequence[str] | None = None
+ self._active_mode_names: Sequence[str] = []
def apply(self, mode_selection: ModeSelectionDefinition) -> None:
- # invalidate active modes
- self._active_mode_names = None
- self._active_modes = None
-
# apply overrides
log.debug("Applying mode selection: default_modes=%s, base_modes=%s", mode_selection.default_modes, mode_selection.base_modes)
if mode_selection.base_modes is not None:
- self._base_modes = mode_selection.base_modes
+ self._configured_base_modes = mode_selection.base_modes
if mode_selection.default_modes is not None:
- self._default_modes = mode_selection.default_modes
- log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._base_modes, self._default_modes)
+ self._configured_default_modes = mode_selection.default_modes
+ log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._configured_base_modes, self._configured_default_modes)
+
+ self._active_mode_names = sorted(set(self._configured_base_modes or []) | set(self._configured_default_modes or []))
def get_mode_names(self) -> Sequence[str]:
- if self._active_mode_names is not None:
- return self._active_mode_names
- active_mode_names: set[str] = set()
- if self._base_modes is not None:
- active_mode_names.update(self._base_modes)
- if self._default_modes is not None:
- active_mode_names.update(self._default_modes)
- self._active_mode_names = sorted(active_mode_names)
- log.info("Active modes: %s", self._active_mode_names)
return self._active_mode_names
+ @classmethod
+ def get_mode_instance(cls, mode_name: str) -> SerenaAgentMode:
+ if mode_name not in cls._mode_instances:
+ cls._mode_instances[mode_name] = SerenaAgentMode.load(mode_name)
+ return cls._mode_instances[mode_name]
+
def get_modes(self) -> Sequence[SerenaAgentMode]:
- if self._active_modes is not None:
- return self._active_modes
- self._active_modes = []
- for mode_name in self.get_mode_names():
- mode = SerenaAgentMode.load(mode_name)
- self._active_modes.append(mode)
- return self._active_modes
+ return [self.get_mode_instance(mode_name) for mode_name in self._active_mode_names]
- # TODO: apply caching like in get_modes
def get_default_modes(self) -> Sequence[SerenaAgentMode]:
- return [SerenaAgentMode.load(mode_name) for mode_name in self._default_modes or []]
+ return [self.get_mode_instance(mode_name) for mode_name in self._configured_default_modes or []]
def get_base_modes(self) -> Sequence[SerenaAgentMode]:
- return [SerenaAgentMode.load(mode_name) for mode_name in self._base_modes or []]
+ return [self.get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []]
+
+
+class ProjectPromptProvisionStatus:
+ """
+ Manages the status of the provision of project-specific prompts
+ """
+
+ @dataclass
+ class SessionStatus:
+ mode_prompts_provided: bool = False
+ project_activation_message_provided: bool = False
+
+ def __init__(self, newly_activated_mode_names: set[str] | None = None):
+ """
+ :param newly_activated_mode_names: list of mode names that have been newly activated (by dynamic project activation)
+ and for which prompts must still be provided (either in the system prompt or via the activation message)
+ """
+ if newly_activated_mode_names is None:
+ newly_activated_mode_names = set()
+ self._newly_activated_mode_names: set[str] = newly_activated_mode_names
+ self._session_status_dict: dict[str, ProjectPromptProvisionStatus.SessionStatus] = defaultdict(lambda: self.SessionStatus())
+
+ def _get_session_status(self, session_id: str) -> SessionStatus:
+ return self._session_status_dict[session_id]
+
+ def is_mode_prompt_already_provided(self, mode_name: str, session_id: str) -> bool:
+ """
+ :param mode_name: the mode name
+ :param session_id: the client session ID
+ :return: whether the mode name was already provided (in a project-specific activation message) and therefore
+ should not be included again (in the Serena instructions manual)
+ """
+ if not self._get_session_status(session_id).mode_prompts_provided:
+ return False
+ return mode_name in self._newly_activated_mode_names
+
+ def get_modes_with_prompts_to_be_provided_for_project_activation(self, session_id: str) -> list[SerenaAgentMode]:
+ """
+ Gets the modes that have been newly activated and for which prompts still need to be provided
+ (in dynamic project activation message).
+
+ :param: session_id: the client session ID
+ :return: the modes
+ """
+ result = []
+
+ # Note: We always want to provide the prompts of newly activated modes in the activation message
+ # because some clients (e.g. Claude Desktop) use a single session for all chats.
+ # Therefore, we view project activation as an "entry action", which must always provide
+ # all the information that is relevant to the project
+ # Because of this, we cannot use a condition like this:
+ # new_mode_prompts_must_be_provided_for_activation = not self._get_session_status(session_id).mode_prompts_provided
+ new_mode_prompts_must_be_provided_for_activation = True
+ mark_used(session_id)
+
+ if new_mode_prompts_must_be_provided_for_activation:
+ for mode_name in self._newly_activated_mode_names:
+ mode = ActiveModes.get_mode_instance(mode_name)
+ if mode.has_prompt():
+ result.append(mode)
+ return result
+
+ def mark_mode_prompts_as_provided(self, session_id: str) -> None:
+ """
+ Marks the prompts for all newly activated modes as provided, so that they will not be included in the project activation message.
+
+ :param session_id: the client session ID
+ """
+ self._get_session_status(session_id).mode_prompts_provided = True
+
+ def mark_project_activation_message_as_provided(self, session_id: str) -> None:
+ """
+ Marks the project activation message as provided, so that it will not be included again in case of multiple activations of the same project.
+
+ :param session_id: the client session ID
+ """
+ self._get_session_status(session_id).project_activation_message_provided = True
+
+ def is_project_activation_message_already_provided(self, session_id: str) -> bool:
+ """
+ :param session_id: the client session ID
+ :return: whether the project activation message was already provided and therefore should not be included again
+ """
+ return self._get_session_status(session_id).project_activation_message_provided
class DashboardManager:
@@ -444,7 +520,8 @@ class SerenaAgent:
self._project_activation_callback = project_activation_callback
self._gui_log_viewer: Optional["GuiLogViewer"] = None
self._dashboard_manager: DashboardManager | None = None
-
+ self._project_prompt_status = ProjectPromptProvisionStatus()
+ self._mode_overrides = modes
self.version = serena_version()
# obtain serena configuration using the decoupled factory function
@@ -453,6 +530,10 @@ class SerenaAgent:
# propagate configuration to other components
self.serena_config.propagate_settings()
+ # initialise active modes (baseline modes prior to project activation)
+ self._active_modes: ActiveModes
+ self._update_active_modes(log_message=False)
+
# determine registered project to be activated (if any)
registered_project_to_activate: RegisteredProject | None = (
self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None
@@ -539,8 +620,6 @@ class SerenaAgent:
# activate the given project (if any), also updating the active modes
# Note: We cannot update the active tools yet, because the base toolset has not been computed yet
# (and its computation depends on the active project)
- self._active_modes: ActiveModes
- self._mode_overrides = modes
if project is not None:
try:
self.activate_project_from_path_or_name(project, update_active_modes=False, update_active_tools=False)
@@ -588,6 +667,7 @@ class SerenaAgent:
"dashboard": int(self.serena_config.web_dashboard),
"version": self.version,
"backend": self._language_backend.value,
+ "context": self._context.name,
}
try:
requests.get("https://oraios-software.de/serena_usage.php", params=params, timeout=1)
@@ -811,7 +891,11 @@ class SerenaAgent:
template = JinjaTemplate(prompt_template)
return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names)
- def create_system_prompt(self) -> str:
+ def create_system_prompt(self, session_id: str = "global") -> str:
+ """
+ :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
+ """
available_tools = self._active_tools
available_markers = available_tools.tool_marker_names
global_memories = MemoriesManager(
@@ -819,28 +903,48 @@ class SerenaAgent:
).list_global_memories()
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
+ # previously provided project activation message (if any)
+ relevant_modes = []
+ for mode in self.get_active_modes():
+ if mode.has_prompt():
+ if not self._project_prompt_status.is_mode_prompt_already_provided(mode.name, session_id):
+ relevant_modes.append(mode)
+ self._project_prompt_status.mark_mode_prompts_as_provided(session_id)
+
system_prompt = self.prompt_factory.create_system_prompt(
context_system_prompt=self._format_prompt(self._context.prompt),
- mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self.get_active_modes()],
+ mode_system_prompts=[self._format_prompt(mode.prompt) for mode in relevant_modes],
available_tools=available_tools.tool_names,
available_markers=available_markers,
global_memories_list=global_memories_str,
)
- # If a project is active at startup, append its activation message
- if self._active_project is not None:
- system_prompt += "\n\n" + self.get_project_activation_message()
+ # provide the project activation message if it hasn't yet been provided
+ if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id):
+ system_prompt += "\n\n" + self.get_project_activation_message(session_id)
log.info("System prompt:\n%s", system_prompt)
return system_prompt
- def get_project_activation_message(self) -> str:
+ def get_project_activation_message(self, session_id: str) -> str:
"""
:return: a message providing information about the project upon activation (e.g. programming language, memories, initial prompt)
:raise: AssertionError if no project is active
"""
proj = self._active_project
assert proj is not None, "A project must be active before calling this."
+
+ # 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.
+ # So while we don't want the activation message to be additionally included in the system prompt
+ # (initial_instructions), an explicit project activation should always return it.
+ # (The check below deliberately left in place for documentation purposes, preventing a regression)
+ if self._project_prompt_status.is_project_activation_message_already_provided(session_id):
+ pass # no special handling
+
+ # provide basic project information (name, location, languages, encoding)
if proj.is_newly_created:
msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}. "
else:
@@ -850,6 +954,7 @@ class SerenaAgent:
msg += f"\nProgramming languages: {languages_str}."
msg += f"File encoding: {proj.project_config.encoding}."
+ # add list of memories (if memories are enabled)
include_memories = self._active_tools.contains_tool_class(ReadMemoryTool)
if include_memories:
project_memories = proj.memories_manager.list_project_memories()
@@ -858,11 +963,24 @@ class SerenaAgent:
f"\n{json.dumps(project_memories.to_dict())}\n"
+ "Use the `read_memory` tool to read these memories later if they are relevant to the task."
)
+
+ # 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)
+ if modes_with_prompts:
+ msg += "\nNewly applicable mode instructions:"
+ for mode in modes_with_prompts:
+ msg += f"\n{mode.prompt}"
+ self._project_prompt_status.mark_mode_prompts_as_provided(session_id)
+
+ # add project-specific prompt
if proj.project_config.initial_prompt:
- msg += f"\nAdditional project-specific instructions:\n {proj.project_config.initial_prompt}"
+ msg += f"\nProject-specific instructions:\n {proj.project_config.initial_prompt}"
+
+ self._project_prompt_status.mark_project_activation_message_as_provided(session_id)
+
return msg
- def _update_active_modes(self) -> None:
+ def _update_active_modes(self, log_message: bool = True) -> None:
"""
Updates the active modes based on the Serena configuration, the active project configuration (if any),
and mode overrides (if any).
@@ -873,6 +991,9 @@ class SerenaAgent:
self._active_modes.apply(self._active_project.project_config)
if self._mode_overrides:
self._active_modes.apply(self._mode_overrides)
+ if log_message:
+ active_mode_names = self._active_modes.get_mode_names()
+ log.info(f"Active modes ({len(active_mode_names)}): {', '.join(active_mode_names)}")
def _update_active_tools(self) -> None:
"""
@@ -964,7 +1085,13 @@ class SerenaAgent:
project.set_agent(self)
if update_active_modes:
+ active_mode_names_before = set(self._active_modes.get_mode_names())
self._update_active_modes()
+ newly_activated_mode_names = set(self._active_modes.get_mode_names()) - active_mode_names_before
+ else:
+ newly_activated_mode_names = None
+
+ self._project_prompt_status = ProjectPromptProvisionStatus(newly_activated_mode_names=newly_activated_mode_names)
if update_active_tools:
self._update_active_tools()
diff --git a/src/serena/cli.py b/src/serena/cli.py
index 8d31ecd1..5e87fa2e 100644
--- a/src/serena/cli.py
+++ b/src/serena/cli.py
@@ -396,7 +396,6 @@ class TopLevelCommands(AutoRegisteringGroup):
) -> None:
prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account."
postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks."
- from serena.tools.workflow_tools import InitialInstructionsTool
lvl = logging.getLevelNamesMapping()[log_level.upper()]
logging.configure(level=lvl)
@@ -410,8 +409,7 @@ class TopLevelCommands(AutoRegisteringGroup):
context=context_instance,
modes=modes_selection_def,
)
- tool = agent.get_tool(InitialInstructionsTool)
- instr = tool.apply()
+ instr = agent.create_system_prompt()
if only_instructions:
print(instr)
else:
diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py
index 2232f66b..2392d2ba 100644
--- a/src/serena/code_editor.py
+++ b/src/serena/code_editor.py
@@ -135,6 +135,13 @@ class CodeEditor(Generic[TSymbol], ABC):
Inserts content after the symbol with the given name in the given file.
"""
symbol = self._find_unique_symbol(name_path, relative_file_path)
+ # Note: for body to be available, the symbol dto that the symbol instance is built from
+ # must have been retrieved either with body or at least with location.
+ # since _find_unique_symbol passes include_location=True, it works here
+ if symbol.body == symbol.name:
+ raise ValueError(
+ f"Cannot insert after this symbol (not a function, class or method): {symbol}. Consider using insert_before_symbol instead."
+ )
# make sure body always ends with at least one newline
if not body.endswith("\n"):
diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py
index 053f54ee..bb3164c9 100644
--- a/src/serena/config/context_mode.py
+++ b/src/serena/config/context_mode.py
@@ -136,6 +136,12 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
return cls.from_name(str(name_or_path))
+ def has_prompt(self) -> bool:
+ """
+ :return: whether this mode defines a prompt
+ """
+ return bool(self.prompt and self.prompt.strip())
+
@dataclass(kw_only=True)
class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin):
diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py
index 2f787f6d..5c8e4e10 100644
--- a/src/serena/config/serena_config.py
+++ b/src/serena/config/serena_config.py
@@ -418,6 +418,10 @@ class ProjectConfig(SharedConfig):
data["languages"] = [data["language"]]
del data["language"]
+ # Note: Checks for validity of fields must not happen here but in _from_dict.
+ # Here, the data may be incomplete, because this function is also used for
+ # loading project.local.yml files.
+
return data, was_complete
@classmethod
@@ -461,13 +465,19 @@ class ProjectConfig(SharedConfig):
line_ending_value = data.get("line_ending")
line_ending = LineEnding.from_str(line_ending_value) if line_ending_value else None
+ # gracefully handle user errors: incorrect use of None/empty where a list is required
+ ignored_paths = data["ignored_paths"] or []
+ fixed_tools = data["fixed_tools"] or []
+ excluded_tools = data["excluded_tools"] or []
+ included_optional_tools = data["included_optional_tools"] or []
+
return cls(
project_name=data["project_name"],
languages=languages,
- ignored_paths=data["ignored_paths"],
- excluded_tools=data["excluded_tools"],
- fixed_tools=data["fixed_tools"],
- included_optional_tools=data["included_optional_tools"],
+ ignored_paths=ignored_paths,
+ excluded_tools=excluded_tools,
+ fixed_tools=fixed_tools,
+ included_optional_tools=included_optional_tools,
read_only=data["read_only"],
read_only_memory_patterns=data.get("read_only_memory_patterns", []),
ignored_memory_patterns=data.get("ignored_memory_patterns", []),
diff --git a/src/serena/project.py b/src/serena/project.py
index 6f1cec43..a7b15f53 100644
--- a/src/serena/project.py
+++ b/src/serena/project.py
@@ -79,7 +79,10 @@ class MemoriesManager:
def get_memory_file_path(self, name: str) -> Path:
# Strip .md extension if present
- name = name.replace(".md", "")
+ name = name.replace(".md", "").replace(os.sep, "/")
+ parts = name.split("/")
+ if ".." in parts:
+ raise ValueError(f"Memory name cannot contain '..' segments for security reasons. Got: {name}")
if self._is_global(name):
if name == self.GLOBAL_TOPIC:
@@ -98,7 +101,7 @@ class MemoriesManager:
# Project-local memory
assert self._project_memory_dir is not None, "Project dir was not passed at initialization"
- parts = name.split("/")
+
filename = f"{parts[-1]}.md"
if len(parts) > 1:
@@ -303,39 +306,42 @@ class Project(ToStringMixin):
f.write(f"/{ProjectConfig.SERENA_LOCAL_PROJECT_FILE}\n")
# prepare ignore spec asynchronously, ensuring immediate project activation.
- self.__ignored_patterns: list[str]
- self.__ignore_spec: pathspec.PathSpec
+ self.__ignored_patterns: list[str] | None = None
+ self.__ignore_spec: pathspec.PathSpec | None = None
self._ignore_spec_available = threading.Event()
threading.Thread(name=f"gather-ignorespec[{self.project_config.project_name}]", target=self._gather_ignorespec, daemon=True).start()
def _gather_ignorespec(self) -> None:
with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log):
- # gather ignored paths from the global configuration, project configuration, and gitignore files
- global_ignored_paths = self.serena_config.ignored_paths
- ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths)
- if len(global_ignored_paths) > 0:
- log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.")
- log.debug(f"Global ignored paths: {list(global_ignored_paths)}")
- if len(self.project_config.ignored_paths) > 0:
- log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.")
- log.debug(f"Project ignored paths: {self.project_config.ignored_paths}")
- log.debug(f"Combined ignored patterns: {ignored_patterns}")
- if self.project_config.ignore_all_files_in_gitignore:
- gitignore_parser = GitignoreParser(self.project_root)
- for spec in gitignore_parser.get_ignore_specs():
- log.debug(f"Adding {len(spec.patterns)} patterns from {spec.file_path} to the ignored paths.")
- ignored_patterns.extend(spec.patterns)
- self.__ignored_patterns = ignored_patterns
+ try:
+ # gather ignored paths from the global configuration, project configuration, and gitignore files
+ global_ignored_paths = self.serena_config.ignored_paths
+ ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths)
+ if len(global_ignored_paths) > 0:
+ log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.")
+ log.debug(f"Global ignored paths: {list(global_ignored_paths)}")
+ if len(self.project_config.ignored_paths) > 0:
+ log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.")
+ log.debug(f"Project ignored paths: {self.project_config.ignored_paths}")
+ log.debug(f"Combined ignored patterns: {ignored_patterns}")
+ if self.project_config.ignore_all_files_in_gitignore:
+ gitignore_parser = GitignoreParser(self.project_root)
+ for spec in gitignore_parser.get_ignore_specs():
+ log.debug(f"Adding {len(spec.patterns)} patterns from {spec.file_path} to the ignored paths.")
+ ignored_patterns.extend(spec.patterns)
+ self.__ignored_patterns = ignored_patterns
- # Set up the pathspec matcher for the ignored paths
- # for all absolute paths in ignored_paths, convert them to relative paths
- processed_patterns = []
- for pattern in ignored_patterns:
- # Normalize separators (pathspec expects forward slashes)
- pattern = pattern.replace(os.path.sep, "/")
- processed_patterns.append(pattern)
- log.debug(f"Processing {len(processed_patterns)} ignored paths")
- self.__ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns)
+ # Set up the pathspec matcher for the ignored paths
+ # for all absolute paths in ignored_paths, convert them to relative paths
+ processed_patterns = []
+ for pattern in ignored_patterns:
+ # Normalize separators (pathspec expects forward slashes)
+ pattern = pattern.replace(os.path.sep, "/")
+ processed_patterns.append(pattern)
+ log.debug(f"Processing {len(processed_patterns)} ignored paths")
+ self.__ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns)
+ except Exception as e:
+ log.error(f"Error while gathering ignore spec for project {self.project_config.project_name}: {e}", exc_info=e)
self._ignore_spec_available.set()
@@ -397,7 +403,12 @@ class Project(ToStringMixin):
if not self._ignore_spec_available.is_set():
log.info("Waiting for ignore spec to become available ...")
self._ignore_spec_available.wait()
- log.info("Ignore spec is now available for project; proceeding")
+ if self.__ignore_spec is not None:
+ log.info("Ignore spec is now available for project; proceeding")
+ if self.__ignore_spec is None:
+ raise ValueError(
+ "The ignore spec could not be computed; please check the log for errors and report here: https://github.com/oraios/serena/issues"
+ )
return self.__ignore_spec
@property
@@ -408,7 +419,12 @@ class Project(ToStringMixin):
if not self._ignore_spec_available.is_set():
log.info("Waiting for ignored patterns to become available ...")
self._ignore_spec_available.wait()
- log.info("Ignore patterns are now available for project; proceeding")
+ if self.__ignored_patterns is not None:
+ log.info("Ignored patterns are now available for project; proceeding")
+ if self.__ignored_patterns is None:
+ raise ValueError(
+ "The ignored patterns could not be computed; please check the log for errors and report here: https://github.com/oraios/serena/issues"
+ )
return self.__ignored_patterns
def _is_ignored_relative_path(self, relative_path: str | Path, ignore_non_source_files: bool = True) -> bool:
diff --git a/src/serena/project_server.py b/src/serena/project_server.py
index dfe5c291..bd6c7a79 100644
--- a/src/serena/project_server.py
+++ b/src/serena/project_server.py
@@ -53,7 +53,7 @@ class ProjectServer:
serena_config.language_backend = LanguageBackend.LSP
self._agent = SerenaAgent(serena_config=serena_config)
- self._loaded_projects_by_name: dict[str, "Project"] = {}
+ self._loaded_projects_by_root: dict[str, "Project"] = {}
# create the Flask application
self._app = Flask(__name__)
@@ -69,26 +69,31 @@ class ProjectServer:
query_request = QueryProjectRequest.model_validate(request.get_json())
return self._query_project(query_request)
- def _get_project(self, project_name: str) -> "Project":
+ def _get_project(self, project_root_or_name: str) -> "Project":
"""Gets the project with the given name, loading it if necessary."""
- if project_name in self._loaded_projects_by_name:
- return self._loaded_projects_by_name[project_name]
- else:
- serena_config = self._agent.serena_config
- registered_project = serena_config.get_registered_project(project_name)
- if registered_project is None:
- raise ValueError(f"Project '{project_name}' is not registered with Serena.")
- with LogTime(f"Loading project '{project_name}'"):
- project = registered_project.get_project_instance(serena_config)
- project.create_language_server_manager()
- self._loaded_projects_by_name[project_name] = project
- return project
+ serena_config = self._agent.serena_config
+ registered_project = serena_config.get_registered_project(project_root_or_name)
+ if registered_project is None:
+ raise ValueError(f"Project '{project_root_or_name}' is not registered with Serena.")
+
+ key = str(registered_project.project_root)
+
+ if key in self._loaded_projects_by_root:
+ return self._loaded_projects_by_root[key]
+
+ with LogTime(f"Loading project '{project_root_or_name}'"):
+ project = registered_project.get_project_instance(serena_config)
+ project.create_language_server_manager()
+ self._loaded_projects_by_root[key] = project
+ return project
def _query_project(self, req: QueryProjectRequest) -> str:
"""Handle a /query_project request by invoking the agent on the specified project and tool."""
project = self._get_project(req.project_name)
with self._agent.active_project_context(project):
tool = self._agent.get_tool_by_name(req.tool_name)
+ if not tool.is_readonly():
+ raise ValueError(f"Tool '{req.tool_name}' is not read-only and cannot be executed via the query_project route")
params = json.loads(req.tool_params_json)
return tool.apply_ex(**params)
diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml
index b6d632a7..fe63878b 100644
--- a/src/serena/resources/config/contexts/claude-code.yml
+++ b/src/serena/resources/config/contexts/claude-code.yml
@@ -7,16 +7,15 @@ prompt: |
**IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them
right now.
For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools.
- For edits, where it makes sense, use Serena's editing tools since they are more token efficient.
- Use your own editing tools only if Serena's editing tools would be suboptimal.
- **IMPORTANT**: Never use a tool before reading its schema via the tool-search tool!
+ For edits, prefer Serena's editing tools since they are more token efficient.
+ Especially when editing code you have read through Serena, ALWAYS use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content).
+ **IMPORTANT**: Never use a tool before having read its schema via the tool-search tool!
excluded_tools:
- create_text_file
- read_file
- execute_shell_command
- - replace_content
- find_file
- list_dir
- search_for_pattern
diff --git a/src/serena/resources/config/contexts/junie.yml b/src/serena/resources/config/contexts/junie.yml
index e8806f4e..8bfdc67d 100644
--- a/src/serena/resources/config/contexts/junie.yml
+++ b/src/serena/resources/config/contexts/junie.yml
@@ -12,6 +12,10 @@ excluded_tools:
- create_text_file
- read_file
- execute_shell_command
+ - replace_content
+ - find_file
+ - list_dir
+ - search_for_pattern
tool_description_overrides: {}
diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js
index df53868e..e48ff3c2 100644
--- a/src/serena/resources/dashboard/dashboard.js
+++ b/src/serena/resources/dashboard/dashboard.js
@@ -485,32 +485,9 @@ class Dashboard {
// Initialize the application
this.loadToolNames().then(function () {
- // Start on overview page
self.loadNews();
- self.loadConfigOverview();
- self.startConfigPolling();
- self.startExecutionsPolling();
- });
- // Initialize heartbeat interval
- setInterval(this.heartbeat.bind(this), 250);
- }
-
- heartbeat() {
- let self = this;
- $.ajax({
- url: '/heartbeat',
- type: 'GET',
- success: function (response) {
- self.heartbeatFailureCount = 0;
- },
- error: function (xhr, status, error) {
- self.heartbeatFailureCount++;
- console.error('Heartbeat failure; count = ', self.heartbeatFailureCount);
- if (self.heartbeatFailureCount >= 1) {
- console.log('Server appears to be down, closing tab');
- window.close();
- }
- },
+ // start on overview page
+ self.navigateToPage("overview");
});
}
@@ -541,7 +518,6 @@ class Dashboard {
// Start appropriate polling for the page
if (page === 'overview') {
this.loadNews();
- this.loadConfigOverview();
this.startConfigPolling();
this.startExecutionsPolling();
} else if (page === 'logs') {
@@ -599,7 +575,8 @@ class Dashboard {
} else {
console.log('Config unchanged, skipping display update');
}
- }, error: function (xhr, status, error) {
+ },
+ error: function (xhr, status, error) {
console.error('Error loading config overview:', error);
self.$configDisplay.html('Error loading configuration
');
self.$basicStatsDisplay.html('Error loading stats
');
@@ -607,24 +584,23 @@ class Dashboard {
self.$availableToolsDisplay.html('Error loading tools
');
self.$availableModesDisplay.html('Error loading modes
');
self.$availableContextsDisplay.html('Error loading contexts
');
- }, complete: function () {
+ },
+ complete: function () {
self.waitingForConfigPollingResult = false;
}
});
}
startConfigPolling() {
+ this.loadConfigOverview();
this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 1000);
}
startExecutionsPolling() {
+ this.loadExecutions()
// Poll every 1 second for executions (independent of config polling)
// This ensures stuck executions can still be cancelled even if config polling is blocked
- this.loadExecutions()
- this.executionsPollInterval = setInterval(() => {
- this.loadQueuedExecutions();
- this.loadLastExecution();
- }, 1000);
+ this.executionsPollInterval = setInterval(this.loadExecutions.bind(this), 1000);
}
displayConfig(config) {
@@ -905,26 +881,30 @@ class Dashboard {
// ===== Executions Methods =====
- loadQueuedExecutions() {
+ loadQueuedExecutions(onComplete) {
let self = this;
$.ajax({
- url: '/queued_task_executions', type: 'GET', success: function (response) {
+ url: '/queued_task_executions', type: 'GET',
+ success: function (response) {
if (response.status === 'success') {
self.displayActiveExecutionsQueue(response.queued_executions || []);
} else {
console.error('Error loading executions:', response.message);
}
- }, error: function (xhr, status, error) {
+ },
+ error: function (xhr, status, error) {
console.error('Error loading executions:', error);
self.$activeExecutionQueueDisplay.html('Error loading executions
');
- }
+ },
+ complete: onComplete
});
}
- loadLastExecution() {
+ loadLastExecution(onComplete) {
let self = this;
$.ajax({
- url: '/last_execution', type: 'GET', success: function (response) {
+ url: '/last_execution', type: 'GET',
+ success: function (response) {
if (response.status === 'success') {
if (response.last_execution !== null && response.last_execution.logged) {
self.displayLastExecution(response.last_execution);
@@ -932,21 +912,28 @@ class Dashboard {
} else {
console.error('Error loading last execution:', response.message);
}
- }, error: function (xhr, status, error) {
+ },
+ error: function (xhr, status, error) {
console.error('Error loading last execution:', error);
self.$lastExecutionDisplay.html('Error loading last execution
');
- }
+ },
+ complete: onComplete
});
}
loadExecutions() {
+ const self = this;
if (this.waitingForExecutionsPollingResult) {
console.log('Still waiting for previous executions poll result, skipping this poll');
- } else {
+ }
+ else {
this.waitingForExecutionsPollingResult = true;
console.log('Polling for executions...');
- this.loadQueuedExecutions();
- this.loadLastExecution();
+ this.loadQueuedExecutions(function() {
+ self.loadLastExecution(function() {
+ self.waitingForExecutionsPollingResult = false;
+ });
+ });
}
}
diff --git a/src/serena/symbol.py b/src/serena/symbol.py
index 72aa4bbd..4a1b14cc 100644
--- a/src/serena/symbol.py
+++ b/src/serena/symbol.py
@@ -15,6 +15,7 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls import LSPFileBuffer
from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol
from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
+from solidlsp.ls_utils import TextUtils
from .ls_manager import LanguageServerManager
from .project import Project
@@ -89,6 +90,16 @@ class Symbol(ToStringMixin, ABC):
def get_body_end_position(self) -> PositionInFile | None:
pass
+ @property
+ @abstractmethod
+ def body(self) -> str | None:
+ pass
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ pass
+
def get_body_start_position_or_raise(self) -> PositionInFile:
"""
Get the start position of the symbol body, raising an error if it is not defined.
@@ -879,6 +890,7 @@ class JetBrainsSymbol(Symbol):
self._cached_file_content: str | None = None
self._cached_body_start_position: PositionInFile | None = None
self._cached_body_end_position: PositionInFile | None = None
+ self._cached_body = symbol_dict.get("body")
def _tostring_includes(self) -> list[str]:
return []
@@ -920,6 +932,25 @@ class JetBrainsSymbol(Symbol):
self._cached_body_end_position = PositionInFile(line=line, col=col)
return self._cached_body_end_position
+ @property
+ def body(self) -> str | None:
+ if self._cached_body is not None:
+ return self._cached_body
+ start_position = self.get_body_start_position()
+ if start_position is None:
+ return None
+ end_position = self.get_body_end_position()
+ assert end_position is not None, "If start position is available, end position should also be available. Symbol: {self}"
+ file_content = self.get_file_content()
+ self._cached_body = TextUtils.get_text_in_range(
+ file_content, start_position.line, start_position.col, end_position.line, end_position.col
+ )
+ return self._cached_body
+
+ @property
+ def name(self) -> str:
+ return self._dict["name_path"].split("/")[-1]
+
def is_neighbouring_definition_separated_by_empty_line(self) -> bool:
# NOTE: Symbol types cannot really be differentiated, because types are not handled in a language-agnostic way.
return False
diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py
index 2008012b..12e4baf0 100644
--- a/src/serena/tools/config_tools.py
+++ b/src/serena/tools/config_tools.py
@@ -1,3 +1,5 @@
+from sensai.util.helper import mark_used
+
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
@@ -22,17 +24,17 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject):
Activates a project based on the project name or path.
"""
- def apply(self, project: str) -> str:
+ # noinspection PyIncorrectDocstring
+ # (session_id is injected via apply_ex)
+ def apply(self, project: str, session_id: str) -> str:
"""
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
"""
is_new_activation = self.agent.activate_project_from_path_or_name(project)
- if not is_new_activation:
- result = "Project was already active."
- else:
- result = self.agent.get_project_activation_message()
+ mark_used(is_new_activation)
+ result = self.agent.get_project_activation_message(session_id)
result += "\nIMPORTANT: If you have not yet read the 'Serena Instructions Manual', do it now before continuing!"
return result
diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py
index b675a0aa..2820ab29 100644
--- a/src/serena/tools/query_project_tools.py
+++ b/src/serena/tools/query_project_tools.py
@@ -16,6 +16,7 @@ class ListQueryableProjectsTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequi
Lists available projects that can be queried with `query_project_tool`.
:param symbol_access: whether to return only projects for which symbol access is available. Default: true
+ :return: project names and roots
"""
# determine relevant projects
registered_projects = self.agent.serena_config.projects
@@ -31,12 +32,9 @@ class ListQueryableProjectsTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequi
else:
relevant_projects = registered_projects
- # return project names, excluding the active project (if any)
- project_names = [p.project_name for p in relevant_projects]
- active_project = self.agent.get_active_project()
- if active_project is not None:
- project_names = [n for n in project_names if n != active_project.project_name]
- return self._to_json(project_names)
+ # return project names and roots
+ result = {p.project_name: str(p.project_root) for p in relevant_projects}
+ return self._to_json(result)
class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject):
@@ -50,7 +48,7 @@ class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveP
Queries a project by executing a read-only Serena tool. The tool will be executed in the context of the project.
Use this to query information from projects other than the activated project.
- :param project_name: the name of the project to query
+ :param project_name: the name of the project to query (or root path)
:param tool_name: the name of the tool to execute in the other project. The tool must be read-only.
:param tool_params_json: the parameters to pass to the tool, encoded as a JSON string
"""
diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py
index 40340e40..a19a99b9 100644
--- a/src/serena/tools/symbol_tools.py
+++ b/src/serena/tools/symbol_tools.py
@@ -369,8 +369,8 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit):
body: str,
) -> str:
"""
- Inserts the given body/content after the end 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.
+ Use this to insert code after a class/method/function definition.
+ Don't use to insert after assignments (constants, fields).
:param name_path: name path of the symbol after which to insert content (definitions in the `find_symbol` tool apply)
:param relative_path: the relative path to the file containing the symbol
diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py
index 7a453ae0..c13e4cd4 100644
--- a/src/serena/tools/tools_base.py
+++ b/src/serena/tools/tools_base.py
@@ -3,6 +3,7 @@ import json
from abc import ABC
from collections.abc import Callable, Iterable
from dataclasses import dataclass
+from functools import cached_property
from types import TracebackType
from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast
@@ -134,9 +135,32 @@ class Tool(Component):
# (which is use by the LLM, so a good description is important)
# 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
"""We can only get the client info from within a tool call. Each tool call will update this variable."""
+ def __init__(self, agent: "SerenaAgent"):
+ 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
def _sanitize_input_param(raw_param: str) -> str:
# some clients replace < and > with their escaped html versions, we need to counteract this
@@ -225,9 +249,9 @@ class Tool(Component):
if apply_fn is None:
raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?")
- return func_metadata(apply_fn, skip_names=["self", "cls"])
+ return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME])
- def _log_tool_application(self, frame: Any) -> None:
+ def _log_tool_application(self, frame: Any, session_id: str) -> None:
params = {}
ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"}
for param, value in frame.f_locals.items():
@@ -237,7 +261,7 @@ class Tool(Component):
params.update(value)
else:
params[param] = value
- log.info(f"{self.get_name_from_cls()}: {dict_string(params)}")
+ log.info(f"{self.get_name_from_cls()}: {dict_string(params)}; session_id: {session_id}")
def _limit_length(
self,
@@ -284,8 +308,10 @@ class Tool(Component):
"""
Applies the tool with logging and exception handling, using the given keyword arguments
"""
+ session_id = "global"
if mcp_ctx is not None:
try:
+ session_id = "%x" % id(mcp_ctx.session)
client_params = mcp_ctx.session.client_params
if client_params is not None:
client_info = cast(Implementation, client_params.clientInfo)
@@ -306,7 +332,7 @@ class Tool(Component):
return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}"
if log_call:
- self._log_tool_application(inspect.currentframe())
+ self._log_tool_application(inspect.currentframe(), session_id)
try:
# check whether the tool requires an active project and language server
if not isinstance(self, ToolMarkerDoesNotRequireActiveProject):
@@ -316,9 +342,14 @@ class Tool(Component):
+ f"{self.agent.serena_config.project_names}"
)
+ # construct apply kwargs, adding session_id if the tool is session-aware
+ apply_kwargs = dict(kwargs)
+ if self._is_session_aware:
+ apply_kwargs["session_id"] = session_id
+
# apply the actual tool
try:
- result = apply_fn(**kwargs)
+ result = apply_fn(**apply_kwargs)
except SolidLSPException as e:
if e.is_language_server_terminated():
affected_language = e.get_affected_language()
@@ -327,7 +358,7 @@ class Tool(Component):
f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..."
)
self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language)
- result = apply_fn(**kwargs)
+ result = apply_fn(**apply_kwargs)
else:
log.error(
f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying."
@@ -337,7 +368,7 @@ class Tool(Component):
raise
# record tool usage
- self.agent.record_tool_usage(kwargs, result, self)
+ self.agent.record_tool_usage(apply_kwargs, result, self)
except Exception as e:
if not catch_exceptions:
diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py
index 7b1f5d96..e50ec9b5 100644
--- a/src/serena/tools/workflow_tools.py
+++ b/src/serena/tools/workflow_tools.py
@@ -63,10 +63,12 @@ class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject):
for clients that do not read the initial instructions when the MCP server is connected.
"""
- def apply(self) -> str:
+ # noinspection PyIncorrectDocstring
+ # (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.
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!
"""
- return self.agent.create_system_prompt()
+ return self.agent.create_system_prompt(session_id=session_id)
diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py
index 9db2a9a4..b54654b8 100644
--- a/src/solidlsp/ls_utils.py
+++ b/src/solidlsp/ls_utils.py
@@ -115,6 +115,15 @@ class TextUtils:
new_l, new_c = TextUtils._get_updated_position_from_line_and_column_and_edit(line, col, text_to_be_inserted)
return new_text, new_l, new_c
+ @staticmethod
+ def get_text_in_range(text: str, start_line: int, start_col: int, end_line: int, end_col: int) -> str:
+ """
+ Returns the text between the given start and end positions.
+ """
+ start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col)
+ end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col)
+ return text[start_idx:end_idx]
+
class PathUtils:
"""
diff --git a/src/solidlsp/lsp_protocol_handler/lsp_types.py b/src/solidlsp/lsp_protocol_handler/lsp_types.py
index ffb6135e..c96f5fce 100644
--- a/src/solidlsp/lsp_protocol_handler/lsp_types.py
+++ b/src/solidlsp/lsp_protocol_handler/lsp_types.py
@@ -2456,8 +2456,8 @@ class DocumentSymbol(TypedDict):
selectionRange: "Range"
""" The range that should be selected and revealed when this symbol is being picked, e.g the name of a function.
Must be contained by the `range`. """
-
- # TODO: I think this type is missing the 'children' field - DJ
+ children: NotRequired[list["DocumentSymbol"]]
+ """ Children of this symbol, e.g. properties of a class. """
class DocumentSymbolRegistrationOptions(TypedDict):
diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr
index f614627e..6c688345 100644
--- a/test/serena/__snapshots__/test_symbol_editing.ambr
+++ b/test/serena/__snapshots__/test_symbol_editing.ambr
@@ -327,7 +327,6 @@
# Module-level variable with type annotation
typed_module_var: int = 42
- new_module_var = "Inserted after typed_module_var"
# Regular class with class and instance variables
@@ -392,6 +391,9 @@
other_result = reassignable_module_var * 2
return result, other_result
+ def new_inserted_function():
+ print("This is a new function inserted before another.")
+
# Create instances and use variables
dataclass_instance = VariableDataclass(id=1, name="Test")
@@ -428,7 +430,6 @@
reassignable_module_var = 20 # Reassigned
# Module-level variable with type annotation
- new_module_var = "Inserted after typed_module_var"
typed_module_var: int = 42
@@ -488,6 +489,9 @@
# Function that uses the module variables
+ def new_inserted_function():
+ print("This is a new function inserted before another.")
+
def use_module_variables():
"""Function that uses module-level variables."""
result = module_var + " used in function"
@@ -511,209 +515,59 @@
# ---
# name: test_insert_in_rel_to_symbol[test_case1-after]
'''
- """
- Test module for variable declarations and usage.
+ export class DemoClass {
+ value: number;
+ constructor(value: number) {
+ this.value = value;
+ }
+ printValue() {
+ console.log(this.value);
+ }
+ }
- This module tests various types of variable declarations and usages including:
- - Module-level variables
- - Class-level variables
- - Instance variables
- - Variable reassignments
- """
+ function newFunctionAfterClass(): void {
+ console.log("This function is after DemoClass.");
+ }
- from dataclasses import dataclass, field
+ export function helperFunction() {
+ const demo = new DemoClass(42);
+ demo.printValue();
+ }
- # Module-level variables
- module_var = "Initial module value"
+ helperFunction();
- reassignable_module_var = 10
- reassignable_module_var = 20 # Reassigned
-
- # Module-level variable with type annotation
- typed_module_var: int = 42
-
-
- # Regular class with class and instance variables
- class VariableContainer:
- """Class that contains various variables."""
-
- # Class-level variables
- class_var = "Initial class value"
-
- reassignable_class_var = True
- reassignable_class_var = False # Reassigned #noqa: PIE794
-
- # Class-level variable with type annotation
- typed_class_var: str = "typed value"
-
- def __init__(self):
- # Instance variables
- self.instance_var = "Initial instance value"
- self.reassignable_instance_var = 100
-
- # Instance variable with type annotation
- self.typed_instance_var: list[str] = ["item1", "item2"]
-
- def modify_instance_var(self):
- # Reassign instance variable
- self.instance_var = "Modified instance value"
- self.reassignable_instance_var = 200 # Reassigned
-
- def use_module_var(self):
- # Use module-level variables
- result = module_var + " used in method"
- other_result = reassignable_module_var + 5
- return result, other_result
-
- def use_class_var(self):
- # Use class-level variables
- result = VariableContainer.class_var + " used in method"
- other_result = VariableContainer.reassignable_class_var
- return result, other_result
-
-
- # Dataclass with variables
- @dataclass
- class VariableDataclass:
- """Dataclass that contains various fields."""
-
- # Field variables with type annotations
- id: int
- name: str
- items: list[str] = field(default_factory=list)
- metadata: dict[str, str] = field(default_factory=dict)
- optional_value: float | None = None
-
- # This will be reassigned in various places
- status: str = "pending"
-
-
- # Function that uses the module variables
- def use_module_variables():
- """Function that uses module-level variables."""
- result = module_var + " used in function"
- other_result = reassignable_module_var * 2
- return result, other_result
-
- def new_inserted_function():
- print("This is a new function inserted before another.")
-
-
- # Create instances and use variables
- dataclass_instance = VariableDataclass(id=1, name="Test")
- dataclass_instance.status = "active" # Reassign dataclass field
-
- # Use variables at module level
- module_result = module_var + " used at module level"
- other_module_result = reassignable_module_var + 30
-
- # Create a second dataclass instance with different status
- second_dataclass = VariableDataclass(id=2, name="Another Test")
- second_dataclass.status = "completed" # Another reassignment of status
+ export function unusedStandaloneFunction(): string {
+ return "This function is not referenced anywhere";
+ }
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case1-before]
'''
- """
- Test module for variable declarations and usage.
+ function newFunctionAfterClass(): void {
+ console.log("This function is after DemoClass.");
+ }
- This module tests various types of variable declarations and usages including:
- - Module-level variables
- - Class-level variables
- - Instance variables
- - Variable reassignments
- """
+ export class DemoClass {
+ value: number;
+ constructor(value: number) {
+ this.value = value;
+ }
+ printValue() {
+ console.log(this.value);
+ }
+ }
- from dataclasses import dataclass, field
+ export function helperFunction() {
+ const demo = new DemoClass(42);
+ demo.printValue();
+ }
- # Module-level variables
- module_var = "Initial module value"
+ helperFunction();
- reassignable_module_var = 10
- reassignable_module_var = 20 # Reassigned
-
- # Module-level variable with type annotation
- typed_module_var: int = 42
-
-
- # Regular class with class and instance variables
- class VariableContainer:
- """Class that contains various variables."""
-
- # Class-level variables
- class_var = "Initial class value"
-
- reassignable_class_var = True
- reassignable_class_var = False # Reassigned #noqa: PIE794
-
- # Class-level variable with type annotation
- typed_class_var: str = "typed value"
-
- def __init__(self):
- # Instance variables
- self.instance_var = "Initial instance value"
- self.reassignable_instance_var = 100
-
- # Instance variable with type annotation
- self.typed_instance_var: list[str] = ["item1", "item2"]
-
- def modify_instance_var(self):
- # Reassign instance variable
- self.instance_var = "Modified instance value"
- self.reassignable_instance_var = 200 # Reassigned
-
- def use_module_var(self):
- # Use module-level variables
- result = module_var + " used in method"
- other_result = reassignable_module_var + 5
- return result, other_result
-
- def use_class_var(self):
- # Use class-level variables
- result = VariableContainer.class_var + " used in method"
- other_result = VariableContainer.reassignable_class_var
- return result, other_result
-
-
- # Dataclass with variables
- @dataclass
- class VariableDataclass:
- """Dataclass that contains various fields."""
-
- # Field variables with type annotations
- id: int
- name: str
- items: list[str] = field(default_factory=list)
- metadata: dict[str, str] = field(default_factory=dict)
- optional_value: float | None = None
-
- # This will be reassigned in various places
- status: str = "pending"
-
-
- # Function that uses the module variables
- def new_inserted_function():
- print("This is a new function inserted before another.")
-
- def use_module_variables():
- """Function that uses module-level variables."""
- result = module_var + " used in function"
- other_result = reassignable_module_var * 2
- return result, other_result
-
-
- # Create instances and use variables
- dataclass_instance = VariableDataclass(id=1, name="Test")
- dataclass_instance.status = "active" # Reassign dataclass field
-
- # Use variables at module level
- module_result = module_var + " used at module level"
- other_module_result = reassignable_module_var + 30
-
- # Create a second dataclass instance with different status
- second_dataclass = VariableDataclass(id=2, name="Another Test")
- second_dataclass.status = "completed" # Another reassignment of status
+ export function unusedStandaloneFunction(): string {
+ return "This function is not referenced anywhere";
+ }
'''
# ---
@@ -729,64 +583,6 @@
}
}
- function newFunctionAfterClass(): void {
- console.log("This function is after DemoClass.");
- }
-
- export function helperFunction() {
- const demo = new DemoClass(42);
- demo.printValue();
- }
-
- helperFunction();
-
- export function unusedStandaloneFunction(): string {
- return "This function is not referenced anywhere";
- }
-
- '''
-# ---
-# name: test_insert_in_rel_to_symbol[test_case2-before]
- '''
- function newFunctionAfterClass(): void {
- console.log("This function is after DemoClass.");
- }
-
- export class DemoClass {
- value: number;
- constructor(value: number) {
- this.value = value;
- }
- printValue() {
- console.log(this.value);
- }
- }
-
- export function helperFunction() {
- const demo = new DemoClass(42);
- demo.printValue();
- }
-
- helperFunction();
-
- export function unusedStandaloneFunction(): string {
- return "This function is not referenced anywhere";
- }
-
- '''
-# ---
-# name: test_insert_in_rel_to_symbol[test_case3-after]
- '''
- export class DemoClass {
- value: number;
- constructor(value: number) {
- this.value = value;
- }
- printValue() {
- console.log(this.value);
- }
- }
-
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
@@ -804,7 +600,7 @@
'''
# ---
-# name: test_insert_in_rel_to_symbol[test_case3-before]
+# name: test_insert_in_rel_to_symbol[test_case2-before]
'''
export class DemoClass {
value: number;
diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py
index be078eae..896b030f 100644
--- a/test/serena/test_serena_agent.py
+++ b/test/serena/test_serena_agent.py
@@ -14,11 +14,14 @@ from serena.config.serena_config import ProjectConfig, RegisteredProject, Serena
from serena.project import Project
from serena.tools import (
SUCCESS_RESULT,
+ ActivateProjectTool,
FindReferencingSymbolsTool,
FindSymbolTool,
+ InitialInstructionsTool,
ReplaceContentTool,
ReplaceSymbolBodyTool,
SafeDeleteSymbol,
+ Tool,
)
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
@@ -697,3 +700,96 @@ class TestSerenaAgent:
assert name_path not in file_content, (
f"Expected symbol {name_path} to be removed from {relative_path}, but it still appears in the file content"
)
+
+
+class TestPromptProvision:
+ class MockContext:
+ def __init__(self, session_id: str):
+ self.session = session_id
+
+ @classmethod
+ def _call_tool(cls, agent: SerenaAgent, tool_class: type[Tool], session_id: str = "global", **kwargs) -> str:
+ result = agent.get_tool(tool_class).apply_ex(mcp_ctx=cls.MockContext(session_id), **kwargs) # type: ignore
+ return result
+
+ @staticmethod
+ def _assert_activation_message(result: str, project_name: str, present: bool) -> None:
+ regex = r"^The project with name '" + project_name + r"'.*?is activated.$"
+ match = re.search(regex, result, re.MULTILINE)
+ if present:
+ assert match is not None, f"Expected project activation message in result:\n{result}"
+ else:
+ assert match is None, f"Expected no project activation message in result:\n{result}"
+
+ @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True)
+ def test_initial_instructions_provide_project_activation_message_once_per_session(self, serena_agent: SerenaAgent) -> None:
+ """
+ Tests that the project activation message is provided on the first call to InitialInstructionsTool for a session,
+ but not on subsequent calls within the same session. #1372
+ """
+ project_name = "test_repo_python"
+ session1 = "session1"
+ session2 = "session2"
+
+ result1 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1)
+ self._assert_activation_message(result1, project_name, present=True)
+
+ result2 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session2)
+ self._assert_activation_message(result2, project_name, present=True)
+
+ result3 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1)
+ self._assert_activation_message(result3, project_name, present=False)
+
+ @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True)
+ def test_dynamically_activated_mode_is_provided_once_per_session(self, serena_agent: SerenaAgent) -> None:
+ """
+ Tests that when a new project is activated within a session that has a different mode configuration (e.g. no-onboarding),
+ the new mode's prompts are provided at project activation but not in subsequent initial instructions calls within the same
+ session, while they are provided in the initial instructions of a new session.
+ """
+ project_name1 = "test_repo_python"
+ project_name2 = "test_repo_java"
+ session1 = "session1"
+ session2 = "session2"
+
+ # the initial instructions must contain the project activation message for the first project
+ result1 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1)
+ self._assert_activation_message(result1, project_name1, present=True)
+
+ # now activate another project which dynamically enables a new mode (no-onboarding)
+ reg_project = serena_agent.serena_config.get_registered_project(project_name2)
+ reg_project.project_config.default_modes = ["no-onboarding"]
+ expected_new_mode_message = "The onboarding process is not applied."
+ result2 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name2, session_id=session1)
+
+ # the new mode's prompt must be included in the activation message
+ self._assert_activation_message(result2, project_name2, present=True)
+ assert expected_new_mode_message in result2, (
+ f"Expected new mode message '{expected_new_mode_message}' not found in result:\n{result2}"
+ )
+
+ # the mode prompt must not be included in subsequent calls to the initial instructions tool within the same session
+ result3 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1)
+ assert expected_new_mode_message not in result3, (
+ f"Expected new mode message '{expected_new_mode_message}' to not be included in subsequent calls, but it was found in result:\n{result3}"
+ )
+
+ # the mode prompt must be included in the initial instructions of a new session
+ result4 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session2)
+ assert expected_new_mode_message in result4, (
+ f"Expected new mode message '{expected_new_mode_message}' to be included in new session, but it was not found in result:\n{result4}"
+ )
+
+ # the initial instructions for the new session must also include the activation message for the project
+ self._assert_activation_message(result4, project_name2, present=True)
+
+ @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True)
+ def test_activate_project_tool_always_returns_activation_message(self, serena_agent: SerenaAgent) -> None:
+ project_name = "test_repo_python"
+ session = "session1"
+
+ result1 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name, session_id=session)
+ self._assert_activation_message(result1, project_name, present=True)
+
+ result2 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name, session_id=session)
+ self._assert_activation_message(result2, project_name, present=True)
diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py
index d6760982..b6f8abf2 100644
--- a/test/serena/test_symbol_editing.py
+++ b/test/serena/test_symbol_editing.py
@@ -328,15 +328,6 @@ class InsertInRelToSymbolTest(EditingTest):
@pytest.mark.parametrize(
"test_case",
[
- pytest.param(
- InsertInRelToSymbolTest(
- Language.PYTHON,
- PYTHON_TEST_REL_FILE_PATH,
- "typed_module_var",
- NEW_PYTHON_VARIABLE,
- ),
- marks=pytest.mark.python,
- ),
pytest.param(
InsertInRelToSymbolTest(
Language.PYTHON,
diff --git a/uv.lock b/uv.lock
index 793e80cb..f5f91eff 100644
--- a/uv.lock
+++ b/uv.lock
@@ -190,23 +190,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
]
-[[package]]
-name = "bleach"
-version = "6.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "webencodings" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
-]
-
-[package.optional-dependencies]
-css = [
- { name = "tinycss2" },
-]
-
[[package]]
name = "blinker"
version = "1.9.0"
@@ -571,15 +554,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
-[[package]]
-name = "defusedxml"
-version = "0.7.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
-]
-
[[package]]
name = "dict2css"
version = "0.3.0.post1"
@@ -1274,15 +1248,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
]
-[[package]]
-name = "jupyterlab-pygments"
-version = "0.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" },
-]
-
[[package]]
name = "latexcodec"
version = "3.0.1"
@@ -1461,15 +1426,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
-[[package]]
-name = "mistune"
-version = "3.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
-]
-
[[package]]
name = "more-itertools"
version = "10.8.0"
@@ -1636,31 +1592,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" },
]
-[[package]]
-name = "nbconvert"
-version = "7.17.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "beautifulsoup4" },
- { name = "bleach", extra = ["css"] },
- { name = "defusedxml" },
- { name = "jinja2" },
- { name = "jupyter-core" },
- { name = "jupyterlab-pygments" },
- { name = "markupsafe" },
- { name = "mistune" },
- { name = "nbclient" },
- { name = "nbformat" },
- { name = "packaging" },
- { name = "pandocfilters" },
- { name = "pygments" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" },
-]
-
[[package]]
name = "nbformat"
version = "5.10.4"
@@ -1676,23 +1607,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" },
]
-[[package]]
-name = "nbsphinx"
-version = "0.9.7"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "docutils" },
- { name = "jinja2" },
- { name = "nbconvert" },
- { name = "nbformat" },
- { name = "sphinx" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/1e/84/b1856b7651ac34e965aa567a158714c7f3bd42a1b1ce76bf423ffb99872c/nbsphinx-0.9.7.tar.gz", hash = "sha256:abd298a686d55fa894ef697c51d44f24e53aa312dadae38e82920f250a5456fe", size = 180479, upload-time = "2025-03-03T19:46:08.069Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/49/2d/8c8e635bcc6757573d311bb3c5445426382f280da32b8cd6d82d501ef4a4/nbsphinx-0.9.7-py3-none-any.whl", hash = "sha256:7292c3767fea29e405c60743eee5393682a83982ab202ff98f5eb2db02629da8", size = 31660, upload-time = "2025-03-03T19:46:06.581Z" },
-]
-
[[package]]
name = "nest-asyncio"
version = "1.6.0"
@@ -1729,15 +1643,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
-[[package]]
-name = "pandocfilters"
-version = "1.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" },
-]
-
[[package]]
name = "parso"
version = "0.8.6"
@@ -2999,8 +2904,6 @@ dev = [
{ name = "jinja2" },
{ name = "jupyter-book" },
{ name = "mypy" },
- { name = "nbconvert" },
- { name = "nbsphinx" },
{ name = "poethepoet" },
{ name = "pyasn1" },
{ name = "pyinstrument" },
@@ -3043,8 +2946,6 @@ requires-dist = [
{ name = "lsprotocol", specifier = "==2025.0.0" },
{ name = "mcp", specifier = "==1.26.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" },
- { name = "nbconvert", marker = "extra == 'dev'", specifier = "==7.17.0" },
- { name = "nbsphinx", marker = "extra == 'dev'", specifier = "==0.9.7" },
{ name = "overrides", specifier = "==7.7.0" },
{ name = "pathspec", specifier = "==0.12.1" },
{ name = "poethepoet", marker = "extra == 'dev'", specifier = "==0.36.0" },
@@ -3647,18 +3548,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
]
-[[package]]
-name = "tinycss2"
-version = "1.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "webencodings" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" },
-]
-
[[package]]
name = "toml-sort"
version = "0.24.2"