ags-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Harness-managed skill library for the spawned CLI agents (claude / agy).
|
|
2
|
+
|
|
3
|
+
Skills are a native CLI feature, not a harness one. The user's **project**
|
|
4
|
+
``.claude/skills/`` already load because the harness runs the CLI with
|
|
5
|
+
``cwd=project_dir`` and ``--add-dir``. This module adds a harness-curated library on
|
|
6
|
+
top, two ways:
|
|
7
|
+
|
|
8
|
+
1. **System-prompt index** (always on when ``inject_index``): a short "available
|
|
9
|
+
skills" note appended to the prompt the harness already builds, so the agent knows
|
|
10
|
+
which managed skills exist and to reach for its native Skill tool. Transport-safe and
|
|
11
|
+
never touches the user's config or repo.
|
|
12
|
+
|
|
13
|
+
2. **Isolated config home** (opt-in, ``isolated_home``): copy the managed ``SKILL.md``
|
|
14
|
+
trees into a harness-owned config dir and point the CLI at it (claude:
|
|
15
|
+
``CLAUDE_CONFIG_DIR``). This makes the skills natively loadable, but relocating the
|
|
16
|
+
config dir means the CLI no longer sees the user's own login — so it is OFF by
|
|
17
|
+
default and intended for setups where the harness supplies the API key
|
|
18
|
+
(``ANTHROPIC_API_KEY`` via ``secret_ref``).
|
|
19
|
+
|
|
20
|
+
A skill is a directory containing a ``SKILL.md`` with YAML frontmatter (``name`` /
|
|
21
|
+
``description``), matching the convention used across the ecosystem.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import shutil
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from harness.observability.logging import get_logger
|
|
33
|
+
from harness.settings import HOME_DIR, get_settings
|
|
34
|
+
|
|
35
|
+
log = get_logger("skills")
|
|
36
|
+
|
|
37
|
+
# Where isolated config homes live, one per provider: ~/.ags/agents/<provider>/
|
|
38
|
+
AGENTS_HOME = HOME_DIR / "agents"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Skill:
|
|
43
|
+
name: str
|
|
44
|
+
description: str
|
|
45
|
+
path: Path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def skills_config() -> dict:
|
|
49
|
+
return get_settings().config().get("skills") or {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _managed_dir(cfg: dict) -> Path:
|
|
53
|
+
return Path(cfg.get("managed_dir", str(HOME_DIR / "skills"))).expanduser()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _bound(cfg: dict, *, provider: str | None, workspace: str | None) -> bool:
|
|
57
|
+
bind = cfg.get("bind") or {}
|
|
58
|
+
provs = bind.get("providers") or []
|
|
59
|
+
wss = bind.get("workspaces") or []
|
|
60
|
+
return (provider is None or not provs or provider in provs) and (
|
|
61
|
+
workspace is None or not wss or workspace in wss
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_frontmatter(text: str) -> dict:
|
|
66
|
+
"""Parse a leading ``---``-delimited YAML block; tolerant of skills without one."""
|
|
67
|
+
if not text.startswith("---"):
|
|
68
|
+
return {}
|
|
69
|
+
end = text.find("\n---", 3)
|
|
70
|
+
if end == -1:
|
|
71
|
+
return {}
|
|
72
|
+
try:
|
|
73
|
+
data = yaml.safe_load(text[3:end])
|
|
74
|
+
except yaml.YAMLError:
|
|
75
|
+
return {}
|
|
76
|
+
return data if isinstance(data, dict) else {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discover(managed_dir: Path | None = None) -> list[Skill]:
|
|
80
|
+
"""Find ``<dir>/<skill>/SKILL.md`` skills and read their name/description."""
|
|
81
|
+
base = managed_dir or _managed_dir(skills_config())
|
|
82
|
+
if not base.is_dir():
|
|
83
|
+
return []
|
|
84
|
+
skills: list[Skill] = []
|
|
85
|
+
for sub in sorted(base.iterdir()):
|
|
86
|
+
md = sub / "SKILL.md"
|
|
87
|
+
if not md.is_file():
|
|
88
|
+
continue
|
|
89
|
+
fm = _parse_frontmatter(md.read_text(encoding="utf-8", errors="ignore"))
|
|
90
|
+
skills.append(Skill(
|
|
91
|
+
name=str(fm.get("name") or sub.name),
|
|
92
|
+
description=str(fm.get("description") or ""),
|
|
93
|
+
path=sub,
|
|
94
|
+
))
|
|
95
|
+
return skills
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def index_text(skills: list[Skill]) -> str:
|
|
99
|
+
"""The "available skills" note injected into the system prompt."""
|
|
100
|
+
if not skills:
|
|
101
|
+
return ""
|
|
102
|
+
lines = ["## Available skills",
|
|
103
|
+
"Managed skills are installed for this session. Use your native Skill tool "
|
|
104
|
+
"to invoke one when relevant:"]
|
|
105
|
+
lines += [f"- {s.name}: {s.description}".rstrip(": ").rstrip() for s in skills]
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def index_for(*, provider: str | None = None, workspace: str | None = None) -> str:
|
|
110
|
+
"""System-prompt skills index for a run, honoring config + binding. Empty string
|
|
111
|
+
when skills are disabled, not bound, or none are installed."""
|
|
112
|
+
cfg = skills_config()
|
|
113
|
+
if not cfg or not cfg.get("inject_index", True):
|
|
114
|
+
return ""
|
|
115
|
+
if not _bound(cfg, provider=provider, workspace=workspace):
|
|
116
|
+
return ""
|
|
117
|
+
return index_text(discover(_managed_dir(cfg)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def provision_config_home(provider: str) -> str | None:
|
|
121
|
+
"""Opt-in: sync managed skills into ``~/.ags/agents/<provider>/skills`` and return
|
|
122
|
+
that home dir, or ``None`` when isolated-home provisioning is disabled / not bound /
|
|
123
|
+
no skills exist. Idempotent (mirrors the managed dir on each call)."""
|
|
124
|
+
cfg = skills_config()
|
|
125
|
+
if not cfg or not cfg.get("isolated_home"):
|
|
126
|
+
return None
|
|
127
|
+
if not _bound(cfg, provider=provider, workspace=None):
|
|
128
|
+
return None
|
|
129
|
+
src = _managed_dir(cfg)
|
|
130
|
+
if not src.is_dir() or not any(src.iterdir()):
|
|
131
|
+
return None
|
|
132
|
+
home = AGENTS_HOME / provider
|
|
133
|
+
dst = home / "skills"
|
|
134
|
+
try:
|
|
135
|
+
if dst.exists():
|
|
136
|
+
shutil.rmtree(dst, ignore_errors=True)
|
|
137
|
+
shutil.copytree(src, dst)
|
|
138
|
+
except OSError as e:
|
|
139
|
+
log.warning("skills_provision_failed", provider=provider, error=str(e))
|
|
140
|
+
return None
|
|
141
|
+
return str(home)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Harness-side tools that adapters can expose to tool-calling models."""
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Filesystem tools for the agentic tool loop (OpenAI-compatible adapter).
|
|
2
|
+
|
|
3
|
+
Gives a tool-calling model read — and, in write mode, write — access to the run's
|
|
4
|
+
project directory, **confined to ``workspace_dir``** so it cannot escape the project
|
|
5
|
+
tree. The harness owns execution and safety here; the adapter only threads the OpenAI
|
|
6
|
+
tool-call protocol. Read tools are always available when tool-calling is on; the write
|
|
7
|
+
tool is offered only when the workspace is in write mode (``read_only=False``).
|
|
8
|
+
|
|
9
|
+
Outputs are bounded (file size, listing count, grep hits) to protect the model's context.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from harness.models.enums import TrustPolicy
|
|
19
|
+
from harness.security.approval_policy import ApprovalRequired, evaluate_gate
|
|
20
|
+
|
|
21
|
+
_MAX_FILE_BYTES = 64_000
|
|
22
|
+
_MAX_LIST = 400
|
|
23
|
+
_MAX_GREP_HITS = 100
|
|
24
|
+
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", ".ruff_cache", ".pytest_cache"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tool_specs(read_only: bool) -> list[dict]:
|
|
28
|
+
"""OpenAI ``tools`` schema for the file tools available in this mode."""
|
|
29
|
+
specs: list[dict] = [
|
|
30
|
+
{
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": "read_file",
|
|
34
|
+
"description": "Read a UTF-8 text file from the project.",
|
|
35
|
+
"parameters": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"path": {"type": "string", "description": "Path relative to project root."}
|
|
39
|
+
},
|
|
40
|
+
"required": ["path"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"type": "function",
|
|
46
|
+
"function": {
|
|
47
|
+
"name": "list_dir",
|
|
48
|
+
"description": "List files and directories under a project path.",
|
|
49
|
+
"parameters": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"path": {"type": "string", "description": "Dir under root (default '.')."}
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"type": "function",
|
|
59
|
+
"function": {
|
|
60
|
+
"name": "grep",
|
|
61
|
+
"description": "Search the project for a regex; returns file:line matches.",
|
|
62
|
+
"parameters": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"pattern": {"type": "string"},
|
|
66
|
+
"path": {"type": "string", "description": "Dir to search (default '.')."},
|
|
67
|
+
},
|
|
68
|
+
"required": ["pattern"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
if not read_only:
|
|
74
|
+
specs.extend([
|
|
75
|
+
{
|
|
76
|
+
"type": "function",
|
|
77
|
+
"function": {
|
|
78
|
+
"name": "write_file",
|
|
79
|
+
"description": "Create or overwrite a UTF-8 text file (write mode only).",
|
|
80
|
+
"parameters": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"properties": {
|
|
83
|
+
"path": {"type": "string"},
|
|
84
|
+
"content": {"type": "string"},
|
|
85
|
+
},
|
|
86
|
+
"required": ["path", "content"],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"type": "function",
|
|
92
|
+
"function": {
|
|
93
|
+
"name": "replace_in_file",
|
|
94
|
+
"description": "Replace a specific string block with another in a UTF-8 text file.",
|
|
95
|
+
"parameters": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"properties": {
|
|
98
|
+
"path": {"type": "string"},
|
|
99
|
+
"old_content": {"type": "string", "description": "The exact content to replace."},
|
|
100
|
+
"new_content": {"type": "string", "description": "The new content to insert."},
|
|
101
|
+
},
|
|
102
|
+
"required": ["path", "old_content", "new_content"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"type": "function",
|
|
108
|
+
"function": {
|
|
109
|
+
"name": "execute_command",
|
|
110
|
+
"description": "Execute a bash command in the project directory. Use this to run tests, scripts, or build tools.",
|
|
111
|
+
"parameters": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"command": {"type": "string", "description": "The bash command to execute."},
|
|
115
|
+
},
|
|
116
|
+
"required": ["command"],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
])
|
|
121
|
+
return specs
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class FileTools:
|
|
125
|
+
"""Executes the file tools against a project root, confined to that root."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
workspace_dir: str,
|
|
130
|
+
read_only: bool = True,
|
|
131
|
+
trust_policy: TrustPolicy = TrustPolicy.restricted,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.root = Path(workspace_dir).resolve()
|
|
134
|
+
self.read_only = read_only
|
|
135
|
+
self.trust_policy = trust_policy
|
|
136
|
+
|
|
137
|
+
def _resolve(self, path: str) -> Path:
|
|
138
|
+
# Confine to the workspace root — reject traversal / symlink escapes.
|
|
139
|
+
# If the model provides an absolute path that starts with the workspace root,
|
|
140
|
+
# or an absolute path in general, strip it to safely apply relative to self.root
|
|
141
|
+
path_str = (path or ".").strip()
|
|
142
|
+
root_str = str(self.root).replace('\\', '/')
|
|
143
|
+
normalized = path_str.replace('\\', '/')
|
|
144
|
+
if normalized.startswith(root_str):
|
|
145
|
+
path_str = path_str[len(root_str):].lstrip('/\\')
|
|
146
|
+
if not path_str:
|
|
147
|
+
path_str = "."
|
|
148
|
+
elif path_str.startswith('/') or (len(path_str) > 1 and path_str[1] == ':'):
|
|
149
|
+
path_str = path_str.lstrip('/\\')
|
|
150
|
+
|
|
151
|
+
p = (self.root / path_str).resolve()
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
if not p.is_relative_to(self.root):
|
|
155
|
+
raise ValueError(f"path escapes project root: {path!r}")
|
|
156
|
+
except AttributeError:
|
|
157
|
+
if p != self.root and self.root.resolve() not in p.parents:
|
|
158
|
+
raise ValueError(f"path escapes project root: {path!r}")
|
|
159
|
+
return p
|
|
160
|
+
|
|
161
|
+
def read_file(self, path: str) -> str:
|
|
162
|
+
p = self._resolve(path)
|
|
163
|
+
if not p.is_file():
|
|
164
|
+
return f"error: not a file: {path}"
|
|
165
|
+
data = p.read_bytes()
|
|
166
|
+
try:
|
|
167
|
+
text = data[:_MAX_FILE_BYTES].decode("utf-8")
|
|
168
|
+
except UnicodeDecodeError:
|
|
169
|
+
return f"error: not a UTF-8 text file: {path}"
|
|
170
|
+
if len(data) > _MAX_FILE_BYTES:
|
|
171
|
+
text += f"\n… (truncated at {_MAX_FILE_BYTES} bytes)"
|
|
172
|
+
return text
|
|
173
|
+
|
|
174
|
+
def list_dir(self, path: str = ".") -> str:
|
|
175
|
+
p = self._resolve(path)
|
|
176
|
+
if not p.is_dir():
|
|
177
|
+
return f"error: not a directory: {path}"
|
|
178
|
+
entries = []
|
|
179
|
+
for child in sorted(p.iterdir())[:_MAX_LIST]:
|
|
180
|
+
if child.name in _SKIP_DIRS:
|
|
181
|
+
continue
|
|
182
|
+
rel = child.relative_to(self.root)
|
|
183
|
+
entries.append(f"{rel}/" if child.is_dir() else str(rel))
|
|
184
|
+
return "\n".join(entries) or "(empty)"
|
|
185
|
+
|
|
186
|
+
def grep(self, pattern: str, path: str = ".") -> str:
|
|
187
|
+
try:
|
|
188
|
+
rx = re.compile(pattern)
|
|
189
|
+
except re.error as e:
|
|
190
|
+
return f"error: bad regex: {e}"
|
|
191
|
+
base = self._resolve(path)
|
|
192
|
+
walk_root = base if base.is_dir() else base.parent
|
|
193
|
+
hits: list[str] = []
|
|
194
|
+
for dirpath, dirnames, filenames in os.walk(walk_root):
|
|
195
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
196
|
+
for fn in filenames:
|
|
197
|
+
fp = Path(dirpath) / fn
|
|
198
|
+
try:
|
|
199
|
+
with fp.open("r", encoding="utf-8", errors="ignore") as fh:
|
|
200
|
+
rel = fp.relative_to(self.root)
|
|
201
|
+
for i, line in enumerate(fh, 1):
|
|
202
|
+
if rx.search(line):
|
|
203
|
+
hits.append(f"{rel}:{i}: {line.rstrip()[:200]}")
|
|
204
|
+
if len(hits) >= _MAX_GREP_HITS:
|
|
205
|
+
return "\n".join(hits) + "\n… (truncated)"
|
|
206
|
+
except OSError:
|
|
207
|
+
continue
|
|
208
|
+
return "\n".join(hits) or "(no matches)"
|
|
209
|
+
|
|
210
|
+
def write_file(self, path: str, content: str) -> str:
|
|
211
|
+
if self.read_only:
|
|
212
|
+
return "error: project is in read-only mode; writes are not permitted"
|
|
213
|
+
p = self._resolve(path)
|
|
214
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
p.write_text(content, encoding="utf-8")
|
|
216
|
+
return f"wrote {len(content)} bytes to {p.relative_to(self.root)}"
|
|
217
|
+
|
|
218
|
+
def replace_in_file(self, path: str, old_content: str, new_content: str) -> str:
|
|
219
|
+
if self.read_only:
|
|
220
|
+
return "error: project is in read-only mode; writes are not permitted"
|
|
221
|
+
p = self._resolve(path)
|
|
222
|
+
if not p.is_file():
|
|
223
|
+
return f"error: not a file: {path}"
|
|
224
|
+
try:
|
|
225
|
+
content = p.read_text(encoding="utf-8")
|
|
226
|
+
except UnicodeDecodeError:
|
|
227
|
+
return f"error: not a UTF-8 text file: {path}"
|
|
228
|
+
if old_content not in content:
|
|
229
|
+
return "error: old_content not found in file"
|
|
230
|
+
# We only replace the first occurrence to avoid unintended changes
|
|
231
|
+
content = content.replace(old_content, new_content, 1)
|
|
232
|
+
p.write_text(content, encoding="utf-8")
|
|
233
|
+
return f"replaced block in {p.relative_to(self.root)}"
|
|
234
|
+
|
|
235
|
+
def execute_command(self, command: str) -> str:
|
|
236
|
+
import subprocess
|
|
237
|
+
if self.read_only:
|
|
238
|
+
return "error: project is in read-only mode; execution is not permitted"
|
|
239
|
+
try:
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
command,
|
|
242
|
+
shell=True,
|
|
243
|
+
cwd=self.root,
|
|
244
|
+
capture_output=True,
|
|
245
|
+
text=True,
|
|
246
|
+
timeout=60,
|
|
247
|
+
)
|
|
248
|
+
out = result.stdout.strip()
|
|
249
|
+
err = result.stderr.strip()
|
|
250
|
+
if not out and not err:
|
|
251
|
+
return f"command executed successfully with no output (exit code {result.returncode})"
|
|
252
|
+
res = ""
|
|
253
|
+
if out:
|
|
254
|
+
res += f"STDOUT:\n{out}\n"
|
|
255
|
+
if err:
|
|
256
|
+
res += f"STDERR:\n{err}\n"
|
|
257
|
+
res += f"Exit code: {result.returncode}"
|
|
258
|
+
return res[:_MAX_FILE_BYTES]
|
|
259
|
+
except subprocess.TimeoutExpired:
|
|
260
|
+
return "error: command timed out after 60 seconds"
|
|
261
|
+
except Exception as e:
|
|
262
|
+
return f"error: {e}"
|
|
263
|
+
|
|
264
|
+
def execute(self, name: str, args: dict, *, bypass_gate: bool = False) -> tuple[str, bool]:
|
|
265
|
+
"""Dispatch a tool by name. Returns ``(output, is_error)``.
|
|
266
|
+
|
|
267
|
+
Raises ``ApprovalRequired`` instead of executing when the gate decides
|
|
268
|
+
this call needs a human decision first (e.g. an un-allowlisted shell
|
|
269
|
+
command). Pass ``bypass_gate=True`` to run a call that has ALREADY been
|
|
270
|
+
approved by a human (the Continuation Run after an approval) — otherwise
|
|
271
|
+
re-evaluating the gate would just raise ``ApprovalRequired`` again."""
|
|
272
|
+
if not bypass_gate:
|
|
273
|
+
decision = evaluate_gate(name, args, trust_policy=self.trust_policy)
|
|
274
|
+
if decision.requires_approval:
|
|
275
|
+
raise ApprovalRequired(name, args, decision)
|
|
276
|
+
try:
|
|
277
|
+
if name == "read_file":
|
|
278
|
+
return self.read_file(args["path"]), False
|
|
279
|
+
if name == "list_dir":
|
|
280
|
+
return self.list_dir(args.get("path", ".")), False
|
|
281
|
+
if name == "grep":
|
|
282
|
+
return self.grep(args["pattern"], args.get("path", ".")), False
|
|
283
|
+
if name == "write_file":
|
|
284
|
+
return self.write_file(args["path"], args["content"]), False
|
|
285
|
+
if name == "replace_in_file":
|
|
286
|
+
return self.replace_in_file(args["path"], args["old_content"], args["new_content"]), False
|
|
287
|
+
if name == "execute_command":
|
|
288
|
+
return self.execute_command(args["command"]), False
|
|
289
|
+
return f"error: unknown tool {name!r}", True
|
|
290
|
+
except KeyError as e:
|
|
291
|
+
return f"error: missing argument {e}", True
|
|
292
|
+
except ValueError as e: # path escape / bad input
|
|
293
|
+
return f"error: {e}", True
|
|
294
|
+
except OSError as e:
|
|
295
|
+
return f"error: {e}", True
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Session dispatch seam.
|
|
2
|
+
|
|
3
|
+
Schedules an in-process ``asyncio`` task (no Redis, no worker process). The run
|
|
4
|
+
executes off the request path and the CLI tails the ledger via SSE."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from harness.workers.inprocess import schedule_session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def dispatch_session(
|
|
14
|
+
session_id: uuid.UUID,
|
|
15
|
+
policy: str,
|
|
16
|
+
providers: list[str] | None,
|
|
17
|
+
additional_objective: str | None = None,
|
|
18
|
+
model_override: str | None = None,
|
|
19
|
+
model_overrides: dict[str, str] | None = None,
|
|
20
|
+
attachments: list[dict] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
schedule_session(
|
|
23
|
+
session_id, policy, providers,
|
|
24
|
+
additional_objective=additional_objective, model_override=model_override,
|
|
25
|
+
model_overrides=model_overrides, attachments=attachments,
|
|
26
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""In-process session executor for this daemonless setup.
|
|
2
|
+
|
|
3
|
+
Runs ``execute_session`` (harness/orchestrator/engine.py) as an ``asyncio`` task on
|
|
4
|
+
the API process's event loop — so ``ags chat`` (async + SSE) works with no separate
|
|
5
|
+
worker process or message broker. Events are written to the durable ``run_events``
|
|
6
|
+
ledger; the SSE endpoint tails them by polling.
|
|
7
|
+
|
|
8
|
+
The task gets its own DB session (the request's session closes when the response is
|
|
9
|
+
sent), a no-op event bus, and the process-local cancellation registry."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from harness.db import get_sessionmaker
|
|
17
|
+
from harness.eventbus import get_event_bus
|
|
18
|
+
from harness.memory.extract import extract_session_memory
|
|
19
|
+
from harness.memory.summarize import summarize_session
|
|
20
|
+
from harness.observability.logging import get_logger
|
|
21
|
+
from harness.orchestrator.engine import execute_session
|
|
22
|
+
from harness.orchestrator.lifecycle import get_canceller
|
|
23
|
+
from harness.orchestrator.policies import compile_policy, load_policy_specs
|
|
24
|
+
from harness.settings import get_settings
|
|
25
|
+
|
|
26
|
+
log = get_logger("inprocess")
|
|
27
|
+
|
|
28
|
+
# Hold strong references to in-flight tasks so they aren't garbage-collected
|
|
29
|
+
# mid-run (asyncio only keeps weak refs to tasks).
|
|
30
|
+
_tasks: set[asyncio.Task[None]] = set()
|
|
31
|
+
|
|
32
|
+
# Per-session locks to serialize concurrent turns for the same session.
|
|
33
|
+
_session_locks: dict[uuid.UUID, asyncio.Lock] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _session_lock(session_id: uuid.UUID) -> asyncio.Lock:
|
|
37
|
+
"""Get or create a lock for the given session ID.
|
|
38
|
+
|
|
39
|
+
Locks are tiny; prune opportunistically when uncontended.
|
|
40
|
+
"""
|
|
41
|
+
if len(_session_locks) > 256:
|
|
42
|
+
for k in [k for k, v in _session_locks.items() if not v.locked()][:128]:
|
|
43
|
+
_session_locks.pop(k, None)
|
|
44
|
+
return _session_locks.setdefault(session_id, asyncio.Lock())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def schedule_session(
|
|
48
|
+
session_id: uuid.UUID,
|
|
49
|
+
policy_name: str,
|
|
50
|
+
providers: list[str] | None,
|
|
51
|
+
additional_objective: str | None = None,
|
|
52
|
+
model_override: str | None = None,
|
|
53
|
+
model_overrides: dict[str, str] | None = None,
|
|
54
|
+
attachments: list[dict] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Fire-and-forget the session execution on the running event loop."""
|
|
57
|
+
task = asyncio.create_task(
|
|
58
|
+
_run(session_id, policy_name, providers, additional_objective, model_override,
|
|
59
|
+
model_overrides, attachments)
|
|
60
|
+
)
|
|
61
|
+
_tasks.add(task)
|
|
62
|
+
task.add_done_callback(_tasks.discard)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def schedule_finalize(session_id: uuid.UUID, *, sessionmaker=None) -> None:
|
|
66
|
+
"""Fire-and-forget background task that runs summarize + extract post-run.
|
|
67
|
+
|
|
68
|
+
Called from the inline (``ags ask``) path after ``execute_session`` so the
|
|
69
|
+
response is returned to the caller immediately, without waiting for the
|
|
70
|
+
(potentially slow) memory-finalization steps.
|
|
71
|
+
|
|
72
|
+
When *sessionmaker* is provided, it is used instead of the process-global
|
|
73
|
+
factory returned by ``get_sessionmaker()``. Pass a sessionmaker bound to
|
|
74
|
+
the caller's engine so the background task hits the same DB as the request
|
|
75
|
+
or test session (the global factory defaults to 127.0.0.1:5432 which may
|
|
76
|
+
not be reachable in test environments).
|
|
77
|
+
|
|
78
|
+
Uses the same strong-reference set + done-callback pattern as
|
|
79
|
+
``schedule_session`` so the task cannot be garbage-collected mid-run.
|
|
80
|
+
"""
|
|
81
|
+
task = asyncio.create_task(_finalize(session_id, sessionmaker=sessionmaker))
|
|
82
|
+
_tasks.add(task)
|
|
83
|
+
task.add_done_callback(_tasks.discard)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _finalize(session_id: uuid.UUID, *, sessionmaker=None) -> None:
|
|
87
|
+
"""Async helper that runs summarize + extract in their own DB session."""
|
|
88
|
+
factory = sessionmaker if sessionmaker is not None else get_sessionmaker()
|
|
89
|
+
async with _session_lock(session_id):
|
|
90
|
+
async with factory() as db:
|
|
91
|
+
try:
|
|
92
|
+
await summarize_session(db, session_id)
|
|
93
|
+
await db.commit()
|
|
94
|
+
await extract_session_memory(db, session_id)
|
|
95
|
+
await db.commit()
|
|
96
|
+
except Exception:
|
|
97
|
+
await db.rollback()
|
|
98
|
+
log.error("inprocess_finalize_failed", session_id=str(session_id), exc_info=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _run(
|
|
102
|
+
session_id: uuid.UUID,
|
|
103
|
+
policy_name: str,
|
|
104
|
+
providers: list[str] | None,
|
|
105
|
+
additional_objective: str | None,
|
|
106
|
+
model_override: str | None,
|
|
107
|
+
model_overrides: dict[str, str] | None = None,
|
|
108
|
+
attachments: list[dict] | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
specs = load_policy_specs(get_settings().config())
|
|
111
|
+
policy_spec = specs.get(policy_name, {"name": policy_name, "mode": policy_name})
|
|
112
|
+
policy = compile_policy(policy_spec, override_providers=providers)
|
|
113
|
+
bus = get_event_bus()
|
|
114
|
+
canceller = get_canceller()
|
|
115
|
+
|
|
116
|
+
async with _session_lock(session_id):
|
|
117
|
+
async with get_sessionmaker()() as db:
|
|
118
|
+
try:
|
|
119
|
+
await execute_session(
|
|
120
|
+
db, session_id=session_id, policy=policy, bus=bus, canceller=canceller,
|
|
121
|
+
message_override=additional_objective, model_override=model_override,
|
|
122
|
+
model_overrides=model_overrides, attachments=attachments,
|
|
123
|
+
)
|
|
124
|
+
# Commit each phase so the SQLite write lock is released promptly. Memory
|
|
125
|
+
# extraction (embeddings) can take seconds; holding one transaction across
|
|
126
|
+
# it blocks a concurrent turn's `create_runs` INSERT and 500s with
|
|
127
|
+
# "database is locked" under the GUI's overlapping requests.
|
|
128
|
+
await db.commit()
|
|
129
|
+
await summarize_session(db, session_id)
|
|
130
|
+
await db.commit()
|
|
131
|
+
await extract_session_memory(db, session_id)
|
|
132
|
+
await db.commit()
|
|
133
|
+
except Exception: # contain — the run row already carries failure state
|
|
134
|
+
await db.rollback()
|
|
135
|
+
log.error("inprocess_session_failed", session_id=str(session_id), exc_info=True)
|