refactor(skills): report touched paths on observations for path-rule injection#4053
Open
VascoSch92 wants to merge 2 commits into
Open
refactor(skills): report touched paths on observations for path-rule injection#4053VascoSch92 wants to merge 2 commits into
VascoSch92 wants to merge 2 commits into
Conversation
9edec9e to
88126b7
Compare
…injection Path-rule injection read the touched file via getattr(action, "path"), an implicit untyped contract. It missed tools whose action names the field differently (Gemini file tools use file_path; apply_patch carries only a patch string) and could fire on a directory a tool merely searched. Add an `affected_paths` property to the Observation base (empty by default) that file-touching observations override to declare the paths they modified. The conversation seam now reads observation.affected_paths directly, dropping the action-correlation lookup and the string-name guess and supporting multiple paths (edits plus renames from a single apply_patch). Producers: file_editor, Gemini read/write/edit, and apply_patch (changed files plus rename destinations). grep/glob/list report nothing, so rules no longer fire on a directory that was only searched. The property is derived from stored fields rather than serialized, so it needs no schema migration and recomputes correctly after deserialization. Closes OpenHands#4038
88126b7 to
b5f1c90
Compare
enyst
reviewed
Jul 9, 2026
enyst
reviewed
Jul 9, 2026
|
|
||
| def _conversation(tmp_path: Path, rule: Skill) -> LocalConversation: | ||
| class _FileObservation(Observation): | ||
| """Minimal path-bearing observation (stands in for a file-tool result). |
Member
Author
There was a problem hiding this comment.
Kept it as a property on the Observation base rather than a Protocol.
A Protocol makes sense when types don't share a base, but every observation already subclasses Observation, and the seam already gets it typed (ObservationEvent.observation: Observation).
The base default (return []) makes affected_paths total, so the consumer just iterates with no isinstance/hasattr guard.
Or wdyt?
Move workspace-relative path normalization for path-rule injection out of LocalConversation._normalize_rule_path into a free function normalize_rule_path in skills/skill.py, beside path_matches_glob (the matcher it feeds). The injection seam now calls it with working_dir passed in; logic is unchanged. Add direct unit tests for the function.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HUMAN:
Refactor path-rule injection.
AGENT:
Why
Path-rule injection discovered the touched file via
getattr(action_event.action, "path", None)inLocalConversation._touched_rule_path— an implicit, untyped contract flaggedwhile reviewing #3994. It fails in two directions with tools that ship today:
file_path, andapply_patchcarries only a patch string — sogetattr(action, "path")returns
Noneand rules silently never fire.grep/glob/list_directorycarry apaththatis a search directory, not a modified file, so a directory a tool merely
searched can trigger
{dir}/**or**rules.It also can't express an operation that touches several files (a rename touches
source and destination; a patch can touch many).
Summary
affected_pathsproperty to theObservationbase (empty by default);file-touching observations override it to declare the paths they modified.
observation.affected_pathsdirectly — droppingthe action-correlation lookup and the
getattrstring-name guess, andsupporting multiple paths.
file_editor, Geminiread/write/edit, andapply_patch(changed files + rename destinations).grep/glob/listreport nothing, so rules no longer fire on a directory that was only searched.
This follows Option C from the issue, adapted: the value is a derived
property, not a stored/serialized field, so it needs no event-schema migration
and recomputes correctly after deserialization (the base is frozen with
extra="forbid", so a serialized computed field would break round-tripvalidation).
Issue Number
Closes #4038
How to Test
Unit + producer coverage:
Broader regression sweep (base-schema + producer changes):
End-to-end (the previously-missed case) — drive a real Gemini
write_fileobservation through a live
LocalConversationand confirm the rule fires:pre-commit run --all-filesscoped to the changed files passes (ruff, pyright,import-dependency rules, tool-registration).
Type
Notes
affected_paths, any file-mutating observation that doesn't override theproperty reports nothing — so all producers are updated in this PR. Third-party
tools that want path rules should override
affected_pathson theirobservation.
read_file/ file-editorviewreport their path(consistent with the pre-existing file-editor behaviour). Erroring ops report
nothing. Happy to gate reads out if reviewers prefer writes-only.
openhands-sdk→openhands-toolslayering is preserved (no newcross-package imports; verified by the import-rules hook).