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.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. 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)