Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/haagent/models/model_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

from collections.abc import Mapping

from haagent.models.config import connections as model_connections
from haagent.models.config.connections import (
DEFAULT_CREDENTIAL_STORE,
ProviderProfileError,
ProvidersConfigSnapshot,
credential_record,
Expand Down Expand Up @@ -54,7 +54,7 @@ def resolve_model(
resolved = resolve_api_key(
credential_record(connection),
environ=environ,
credential_store=credential_store or DEFAULT_CREDENTIAL_STORE,
credential_store=credential_store or model_connections.DEFAULT_CREDENTIAL_STORE,
config_dir=snapshot.path.parent,
)
except CredentialError as error:
Expand Down
6 changes: 5 additions & 1 deletion src/haagent/multi_agent/team_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import os
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from itertools import count
from pathlib import Path
from typing import Any, Literal

Expand Down Expand Up @@ -50,6 +51,7 @@ class TeamRecord:
class TeamStore:
def __init__(self, root: Path) -> None:
self.root = root
self._message_sequence = count()

def ensure_team(
self,
Expand Down Expand Up @@ -179,7 +181,9 @@ def write_worker_message(self, team_id: str, agent_id: str, message: WorkerMessa
payload = message.to_dict()
inbox = self._team_dir(team_id) / "agents" / _safe_id(agent_id) / "messages"
inbox.mkdir(parents=True, exist_ok=True)
path = inbox / f"{payload['created_at']}-{payload['message_id']}.json"
# Windows 上连续创建消息可能得到相同时间戳,序号保留实际写入顺序。
sequence = next(self._message_sequence)
path = inbox / f"{payload['created_at']}-{sequence:020d}-{payload['message_id']}.json"
return _atomic_write_json(path, payload)

def read_worker_messages(self, team_id: str, agent_id: str) -> list[WorkerMessage]:
Expand Down
6 changes: 5 additions & 1 deletion src/haagent/runtime/execution/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,11 @@ def redact_secret_like_text(text: str) -> tuple[str, bool]:
redacted = KEY_VALUE_PATTERN.sub(lambda match: f"{match.group(1)}={REDACTED_SECRET}", text)
redacted = SECRET_TOKEN_PATTERN.sub(REDACTED_TOKEN, redacted)
for value in _secret_environment_values():
redacted = redacted.replace(value, REDACTED_SECRET)
redacted = re.sub(
rf"(?<![A-Za-z0-9_]){re.escape(value)}(?![A-Za-z0-9_])",
REDACTED_SECRET,
redacted,
)
return redacted, redacted != text


Expand Down
63 changes: 63 additions & 0 deletions src/haagent/tools/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import subprocess
import time
from difflib import SequenceMatcher, unified_diff
from fnmatch import fnmatchcase
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -1059,6 +1060,7 @@ def _python_search_paths(
return git_paths

paths: list[Path] = []
gitignore_rules = _gitignore_rules(root)

def onerror(error: OSError) -> None:
skipped = Path(error.filename) if error.filename else root
Expand All @@ -1079,11 +1081,72 @@ def onerror(error: OSError) -> None:
continue
path = current_path / filename
relative = path.relative_to(root)
if _is_gitignored(relative, gitignore_rules):
continue
if include is None or relative.match(include):
paths.append(path)
return paths


def _gitignore_rules(root: Path) -> list[tuple[Path, str, bool, bool]]:
"""读取 Python 后备搜索所需的最小 .gitignore 规则集。"""
rules: list[tuple[Path, str, bool, bool]] = []
for current, directories, files in os.walk(root, topdown=True):
directories[:] = sorted(
directory
for directory in directories
if directory not in GREP_NOISE_DIRECTORIES and not directory.startswith(".")
)
if ".gitignore" not in files:
continue
ignore_file = Path(current) / ".gitignore"
try:
lines = ignore_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
continue
base = ignore_file.parent.relative_to(root)
for raw_pattern in lines:
pattern = raw_pattern.strip()
if not pattern or pattern.startswith("#"):
continue
negated = pattern.startswith("!")
if negated:
pattern = pattern[1:]
directory_only = pattern.endswith("/")
pattern = pattern.rstrip("/")
if pattern:
rules.append((base, pattern, negated, directory_only))
return rules


def _is_gitignored(path: Path, rules: list[tuple[Path, str, bool, bool]]) -> bool:
ignored = False
for base, pattern, negated, directory_only in rules:
try:
relative = path.relative_to(base)
except ValueError:
continue
if _gitignore_rule_matches(relative, pattern, directory_only):
ignored = not negated
return ignored


def _gitignore_rule_matches(path: Path, pattern: str, directory_only: bool) -> bool:
anchored = pattern.startswith("/")
pattern = pattern.lstrip("/")
candidates = [path]
if directory_only:
candidates = [Path(*path.parts[:index]) for index in range(1, len(path.parts))]
for candidate in candidates:
relative = candidate.as_posix()
if anchored or "/" in pattern:
if fnmatchcase(relative, pattern):
return True
elif any(fnmatchcase(part, pattern) for part in candidate.parts):
return True
return False


def _git_search_paths(root: Path, include: str | None, deadline: float) -> list[Path] | None:
git = shutil.which("git")
if git is None or root.name in GREP_NOISE_DIRECTORIES:
Expand Down
6 changes: 6 additions & 0 deletions src/haagent/tools/shell_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def _resolve_path_token(
expanded = _expand_environment(value, environ)
if expanded is None:
return None
expanded = _normalize_path_separators(expanded)
wildcard = min((expanded.find(mark) for mark in "*?[" if mark in expanded), default=-1)
if wildcard >= 0:
prefix = expanded[:wildcard].rstrip("/\\")
Expand All @@ -156,6 +157,11 @@ def _resolve_path_token(
return candidate.resolve()


def _normalize_path_separators(value: str) -> str:
"""将 PowerShell 分隔符转换为当前宿主文件系统可识别的形式。"""
return value if os.name == "nt" else value.replace("\\", "/")


def _expand_environment(value: str, environ: Mapping[str, str]) -> str | None:
unresolved = False

Expand Down
2 changes: 2 additions & 0 deletions tests/integration/app/test_assistant_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from haagent.models.types import ModelGatewayMetadata, ModelResponse
from haagent.models.model_settings import ModelSettings
from haagent.models.config import connections as model_connections
from haagent.models import model_resolution
from haagent.models.config.config_store import ModelConfigStore
from haagent.models.config.connections import ProviderConnectionRecord
from haagent.models.model_ref import ModelRef
Expand Down Expand Up @@ -581,6 +582,7 @@ def test_service_selects_models_from_named_connection_without_rewriting_key(
_set_home(monkeypatch, home)
store = FakeCredentialStore()
monkeypatch.setattr(model_connections, "DEFAULT_CREDENTIAL_STORE", store)
monkeypatch.setattr(model_resolution, "DEFAULT_CREDENTIAL_STORE", FakeCredentialStore({}), raising=False)
service = _service(tmp_path)

connection = service.models.configure_connection(
Expand Down
7 changes: 6 additions & 1 deletion tests/integration/tools/test_tool_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1371,13 +1371,18 @@ def test_grep_finds_matching_text_and_writes_trace(tmp_path: Path) -> None:
assert json.loads(trace_lines[0])["tool_name"] == "grep"


def test_grep_default_respects_ignore_files_and_noise_directories(tmp_path: Path) -> None:
def test_grep_python_fallback_respects_ignore_files_and_noise_directories(
tmp_path: Path,
monkeypatch,
) -> None:
(tmp_path / ".gitignore").write_text("ignored/\n", encoding="utf-8")
(tmp_path / "ignored").mkdir()
(tmp_path / "ignored" / "ignored.txt").write_text("needle\n", encoding="utf-8")
(tmp_path / ".tmp").mkdir()
(tmp_path / ".tmp" / "blocked.txt").write_text("needle\n", encoding="utf-8")
(tmp_path / "kept.txt").write_text("needle\n", encoding="utf-8")
original_which = file_tools.shutil.which
monkeypatch.setattr(file_tools.shutil, "which", lambda name: None if name == "rg" else original_which(name))

result = file_tools.grep({"pattern": "needle"}, tmp_path)

Expand Down
16 changes: 14 additions & 2 deletions tests/unit/multi_agent/test_team_store_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,24 @@ def test_team_store_writes_and_reads_worker_messages(tmp_path: Path) -> None:
store.write_worker_message(
"team-1",
"worker-1",
WorkerMessage(sender="coordinator", recipient="worker-1", content="first"),
WorkerMessage(
sender="coordinator",
recipient="worker-1",
content="first",
message_id="z-first",
created_at=100.0,
),
)
store.write_worker_message(
"team-1",
"worker-1",
WorkerMessage(sender="coordinator", recipient="worker-1", content="second"),
WorkerMessage(
sender="coordinator",
recipient="worker-1",
content="second",
message_id="a-second",
created_at=100.0,
),
)

messages = store.read_worker_messages("team-1", "worker-1")
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/runtime/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,23 @@
build_shell_command_argv,
build_python_utf8_environment,
describe_shell_contract,
redact_secret_like_text,
resolve_shell_command,
resolve_shell_contract,
run_command,
run_process,
)


def test_secret_environment_value_does_not_redact_substrings_of_regular_output(monkeypatch) -> None:
monkeypatch.setenv("HAAGENT_TEST_SECRET_TOKEN", "root")

redacted, changed = redact_secret_like_text("C:/tmp/workspace_root0")

assert redacted == "C:/tmp/workspace_root0"
assert changed is False


def test_shell_contract_selects_powershell_on_windows_and_drives_argv() -> None:
def fake_which(name: str) -> str | None:
return {"pwsh": "C:/Program Files/PowerShell/7/pwsh.exe"}.get(name)
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/scheduling/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def _valid_definition(**overrides: object) -> ScheduleDefinition:
"id": "sch_test",
"name": "daily report",
"prompt": "Summarize the workspace",
"workspace_root": Path("E:/workspace/project"),
"workspace_root": Path.cwd(),
"destination_kind": "new_session",
"destination_session_path": None,
"connection_id": "conn_local",
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/tools/test_shell_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@

from pathlib import Path

import haagent.tools.shell_paths as shell_paths
from haagent.tools.shell_paths import collect_shell_paths


def test_normalize_path_separators_converts_powershell_separators_on_posix(monkeypatch) -> None:
monkeypatch.setattr(shell_paths.os, "name", "posix")

assert shell_paths._normalize_path_separators(r"home\.config\settings.json") == "home/.config/settings.json"


def test_collect_shell_paths_expands_powershell_environment_path(
tmp_path: Path,
monkeypatch,
Expand Down