Skip to content

refactor(skills): report touched paths on observations for path-rule injection#4053

Open
VascoSch92 wants to merge 2 commits into
OpenHands:mainfrom
VascoSch92:refactor/observation-affected-paths
Open

refactor(skills): report touched paths on observations for path-rule injection#4053
VascoSch92 wants to merge 2 commits into
OpenHands:mainfrom
VascoSch92:refactor/observation-affected-paths

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Jul 9, 2026

Copy link
Copy Markdown
Member

HUMAN:

Refactor path-rule injection.


AGENT:

Why

Path-rule injection discovered the touched file via
getattr(action_event.action, "path", None) in
LocalConversation._touched_rule_path — an implicit, untyped contract flagged
while reviewing #3994. It fails in two directions with tools that ship today:

  • Misses tools. The Gemini file tools name the field file_path, and
    apply_patch carries only a patch string — so getattr(action, "path")
    returns None and rules silently never fire.
  • Fires spuriously. grep / glob / list_directory carry a path that
    is 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

  • Add an affected_paths property to the Observation base (empty by default);
    file-touching observations override it to declare the paths they modified.
  • The injection seam now reads observation.affected_paths directly — dropping
    the action-correlation lookup and the getattr string-name guess, and
    supporting multiple paths.
  • Producers populate it: file_editor, Gemini read/write/edit, and
    apply_patch (changed files + rename destinations). grep/glob/list
    report 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-trip
validation).

Issue Number

Closes #4038

How to Test

Unit + producer coverage:

uv run pytest tests/sdk/conversation/test_path_rules_injection.py \
              tests/tools/test_path_rules_file_editor.py \
              tests/tools/test_affected_paths.py -q
# 22 passed

Broader regression sweep (base-schema + producer changes):

uv run pytest tests/sdk/tool tests/tools/{file_editor,apply_patch,gemini,grep,glob} \
              tests/sdk/conversation -q
# 1226 passed

End-to-end (the previously-missed case) — drive a real Gemini write_file
observation through a live LocalConversation and confirm the rule fires:

getattr(action, 'path'): None            <- what the old seam saw (missed it)
observation.affected_paths: ['/.../src/api/users.ts']
...
RESULT: rule fired for a Gemini write_file. activated_path_rules = ['api']

pre-commit run --all-files scoped to the changed files passes (ruff, pyright,
import-dependency rules, tool-registration).

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Producer + consumer land together (by design). Once the seam reads
    affected_paths, any file-mutating observation that doesn't override the
    property reports nothing — so all producers are updated in this PR. Third-party
    tools that want path rules should override affected_paths on their
    observation.
  • Reads count as touches. read_file / file-editor view report their path
    (consistent with the pre-existing file-editor behaviour). Erroring ops report
    nothing. Happy to gate reads out if reviewers prefer writes-only.
  • The core openhands-sdkopenhands-tools layering is preserved (no new
    cross-package imports; verified by the import-rules hook).

@VascoSch92 VascoSch92 force-pushed the refactor/observation-affected-paths branch 3 times, most recently from 9edec9e to 88126b7 Compare July 9, 2026 13:04
…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
@VascoSch92 VascoSch92 force-pushed the refactor/observation-affected-paths branch from 88126b7 to b5f1c90 Compare July 9, 2026 13:17
@VascoSch92 VascoSch92 requested a review from enyst July 9, 2026 13:25
@VascoSch92 VascoSch92 marked this pull request as ready for review July 9, 2026 13:25
Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py Outdated

def _conversation(tmp_path: Path, rule: Skill) -> LocalConversation:
class _FileObservation(Observation):
"""Minimal path-bearing observation (stands in for a file-tool result).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be a protocol…? 🤔

@VascoSch92 VascoSch92 Jul 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@enyst

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cleanup: replace implicit getattr(action, "path") contract for path-rule injection with a typed design

2 participants