local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from threading import Event
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..repository_tools import BoundedRepositoryTools, ToolCancelled, ToolPolicyError
|
|
10
|
+
from ..semantic_linter import lint_patch_in_memory
|
|
11
|
+
from ..task import TaskEnvelope
|
|
12
|
+
from ..validators import _normalize_task_path, apply_patch, check_patch_applies, parse_unified_diff
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_post_apply_checks(
|
|
16
|
+
task: TaskEnvelope,
|
|
17
|
+
tools: BoundedRepositoryTools,
|
|
18
|
+
*,
|
|
19
|
+
active_cancel: Event | None,
|
|
20
|
+
audit: list[dict[str, Any]],
|
|
21
|
+
) -> tuple[list[dict[str, Any]], bool]:
|
|
22
|
+
"""Run each allowlisted check against the already-applied workspace.
|
|
23
|
+
|
|
24
|
+
Module-level so the mediated apply path in ``DelegationService`` reuses the
|
|
25
|
+
exact same evidence contract without re-entering the model loop. Raises
|
|
26
|
+
``ToolCancelled`` when cancellation is requested between checks.
|
|
27
|
+
"""
|
|
28
|
+
checks: list[dict[str, Any]] = []
|
|
29
|
+
if not task.checks:
|
|
30
|
+
raise ToolPolicyError("at least one targeted check is required before apply")
|
|
31
|
+
for command in task.checks:
|
|
32
|
+
if active_cancel is not None and active_cancel.is_set():
|
|
33
|
+
raise ToolCancelled("task was cancelled")
|
|
34
|
+
check = tools.execute("run_tests", {"command": command})
|
|
35
|
+
observed = {
|
|
36
|
+
"command": command,
|
|
37
|
+
"passed": check["passed"],
|
|
38
|
+
"evidence": check["evidence"],
|
|
39
|
+
"stdout": check.get("stdout", ""),
|
|
40
|
+
"stderr": check.get("stderr", ""),
|
|
41
|
+
"exit_code": check.get("exit_code"),
|
|
42
|
+
}
|
|
43
|
+
checks.append(observed)
|
|
44
|
+
audit.append(
|
|
45
|
+
{
|
|
46
|
+
"event": "post_apply_check",
|
|
47
|
+
"command": command,
|
|
48
|
+
"passed": check["passed"],
|
|
49
|
+
"exit_code": check.get("exit_code"),
|
|
50
|
+
"stdout": check.get("stdout", ""),
|
|
51
|
+
"stderr": check.get("stderr", ""),
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
return checks, all(check["passed"] for check in checks)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def add_risk(result: dict[str, Any], kind: str, message: str) -> None:
|
|
58
|
+
risks = result.get("risks")
|
|
59
|
+
if not isinstance(risks, list):
|
|
60
|
+
risks = []
|
|
61
|
+
result["risks"] = risks
|
|
62
|
+
risks.append({"kind": kind, "message": message})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_failure(kind: str, message: str, audit: list[dict[str, Any]]) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"status": "failed",
|
|
68
|
+
"error": {"kind": kind, "message": message},
|
|
69
|
+
"audit": [*audit, {"event": "apply_rejected", "kind": kind}],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _file_digest(path: Path) -> str | None:
|
|
74
|
+
try:
|
|
75
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
return None
|
|
78
|
+
except OSError:
|
|
79
|
+
return "unreadable"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _baseline(workspace: Path, changed: tuple[str, ...]) -> dict[str, str | None]:
|
|
83
|
+
return {name: _file_digest(workspace / name) for name in changed}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _restored(workspace: Path, baseline: dict[str, str | None]) -> bool:
|
|
87
|
+
return all(_file_digest(workspace / name) == digest for name, digest in baseline.items())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def mediated_apply(
|
|
91
|
+
workspace: Path,
|
|
92
|
+
patch: str,
|
|
93
|
+
task: TaskEnvelope,
|
|
94
|
+
*,
|
|
95
|
+
cancel_event: Event | None = None,
|
|
96
|
+
audit: list[dict[str, Any]] | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Apply a reviewed patch under the full verification gate.
|
|
99
|
+
|
|
100
|
+
Single controller-owned implementation shared by the service, CLI and
|
|
101
|
+
desktop apply paths: refuse a patch with no targeted check, enforce the
|
|
102
|
+
declared file scope, pre-check and lint the patch, apply it, run the
|
|
103
|
+
allowlisted checks, and roll back to the recorded baseline on any failure.
|
|
104
|
+
A rollback is only reported as successful when the baseline digests are
|
|
105
|
+
actually restored; otherwise the workspace is reported as modified/unknown.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
audit = list(audit) if audit is not None else []
|
|
109
|
+
if not isinstance(patch, str) or not patch.strip():
|
|
110
|
+
return apply_failure("no_patch", "proposal carries no resolved patch", audit)
|
|
111
|
+
if not task.checks:
|
|
112
|
+
return apply_failure(
|
|
113
|
+
"apply_requires_checks",
|
|
114
|
+
"applying a non-empty patch requires at least one targeted check",
|
|
115
|
+
audit,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
changed, issues = parse_unified_diff(patch)
|
|
119
|
+
if issues:
|
|
120
|
+
return apply_failure("patch_parse_failed", "; ".join(issues), audit)
|
|
121
|
+
allowed = {_normalize_task_path(name) for name in task.files}
|
|
122
|
+
outside = [name for name in changed if _normalize_task_path(name) not in allowed]
|
|
123
|
+
if outside:
|
|
124
|
+
return apply_failure(
|
|
125
|
+
"patch_outside_scope",
|
|
126
|
+
f"patch touches files outside the declared scope: {', '.join(outside)}",
|
|
127
|
+
audit,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
applies, detail = check_patch_applies(workspace, patch)
|
|
132
|
+
except ValueError as error:
|
|
133
|
+
return apply_failure("apply_policy", str(error), audit)
|
|
134
|
+
if not applies:
|
|
135
|
+
return apply_failure(
|
|
136
|
+
"stale_workspace",
|
|
137
|
+
f"proposal no longer applies cleanly to the workspace: {detail}",
|
|
138
|
+
audit,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Semantic linter pre-gate (R18): never mutate the workspace with a
|
|
142
|
+
# patch that introduces syntax errors.
|
|
143
|
+
lint_report = lint_patch_in_memory(str(workspace), patch)
|
|
144
|
+
if not lint_report.valid:
|
|
145
|
+
return apply_failure(
|
|
146
|
+
"semantic_lint_failed",
|
|
147
|
+
"; ".join(lint_report.prescriptions) or "patch failed static analysis",
|
|
148
|
+
audit,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
baseline = _baseline(workspace, changed)
|
|
152
|
+
try:
|
|
153
|
+
tools = BoundedRepositoryTools(workspace, task, cancel_event=cancel_event)
|
|
154
|
+
except (ToolPolicyError, ValueError) as error:
|
|
155
|
+
return apply_failure("apply_policy", str(error), audit)
|
|
156
|
+
|
|
157
|
+
applied, apply_detail = apply_patch(workspace, patch)
|
|
158
|
+
if not applied:
|
|
159
|
+
return apply_failure("apply_failed", f"patch could not be applied: {apply_detail}", audit)
|
|
160
|
+
audit.append({"event": "patch_applied"})
|
|
161
|
+
|
|
162
|
+
result: dict[str, Any] = {"status": "accepted", "checks": [], "risks": [], "audit": audit}
|
|
163
|
+
try:
|
|
164
|
+
post_checks, post_checks_passed = run_post_apply_checks(
|
|
165
|
+
task, tools, active_cancel=cancel_event, audit=audit
|
|
166
|
+
)
|
|
167
|
+
except ToolCancelled:
|
|
168
|
+
result["status"] = "failed"
|
|
169
|
+
result["error"] = {"kind": "cancelled", "message": "post-apply checks were cancelled"}
|
|
170
|
+
audit.append({"event": "post_apply_cancelled"})
|
|
171
|
+
except ToolPolicyError as error:
|
|
172
|
+
result["status"] = "rejected"
|
|
173
|
+
result["error"] = {"kind": "post_apply_check_failed", "message": f"post-apply check could not complete: {error}"}
|
|
174
|
+
audit.append({"event": "post_apply_check_error", "detail": str(error)})
|
|
175
|
+
except Exception: # noqa: BLE001 - apply boundary must roll back, never leave a half-applied patch.
|
|
176
|
+
result["status"] = "failed"
|
|
177
|
+
result["error"] = {"kind": "apply_error", "message": "post-apply checks raised an unexpected error"}
|
|
178
|
+
audit.append({"event": "post_apply_check_error", "detail": "unexpected_error"})
|
|
179
|
+
else:
|
|
180
|
+
result["checks"] = post_checks
|
|
181
|
+
if post_checks_passed:
|
|
182
|
+
result["applied"] = True
|
|
183
|
+
result["workspace_state"] = "original"
|
|
184
|
+
audit.append({"event": "post_apply_checks_passed"})
|
|
185
|
+
return result
|
|
186
|
+
result["status"] = "rejected"
|
|
187
|
+
result["error"] = {"kind": "post_apply_check_failed", "message": "a targeted check failed after applying the patch"}
|
|
188
|
+
audit.append({"event": "post_apply_check_failed"})
|
|
189
|
+
|
|
190
|
+
# Reached only on a cancelled / policy-error / unexpected-error / failed
|
|
191
|
+
# check: roll the patch back. A failed rollback is surfaced explicitly so
|
|
192
|
+
# a consumer never mistakes a still-modified workspace for a clean one.
|
|
193
|
+
try:
|
|
194
|
+
rollback_ok, rollback_detail = apply_patch(workspace, patch, reverse=True)
|
|
195
|
+
except Exception as error: # noqa: BLE001 - expose uncertain workspace state
|
|
196
|
+
rollback_ok = False
|
|
197
|
+
rollback_detail = str(error)
|
|
198
|
+
if rollback_ok and not _restored(workspace, baseline):
|
|
199
|
+
# ponytail: git apply --reverse can exit 0 without reverting every file
|
|
200
|
+
# (e.g. a partially applied hunk). Compare baseline digests instead of
|
|
201
|
+
# trusting the exit code; verify by content if this ever false-positives.
|
|
202
|
+
rollback_ok = False
|
|
203
|
+
rollback_detail = "rollback did not restore the recorded baseline"
|
|
204
|
+
|
|
205
|
+
if rollback_ok:
|
|
206
|
+
result["rolled_back"] = True
|
|
207
|
+
result["workspace_state"] = "original"
|
|
208
|
+
audit.append({"event": "patch_rolled_back"})
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
result["rolled_back"] = False
|
|
212
|
+
result["workspace_modified"] = True
|
|
213
|
+
result["workspace_state"] = "unknown"
|
|
214
|
+
add_risk(result, "rollback_failed", f"patch rollback failed: {rollback_detail}")
|
|
215
|
+
audit.append({"event": "rollback_failed", "detail": rollback_detail})
|
|
216
|
+
return result
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Harness-agnostic delegating agent: decompose, delegate, decompose further.
|
|
2
|
+
|
|
3
|
+
The delegating agent owns the outer "expensive agent" loop: it breaks a wide
|
|
4
|
+
task into bounded children using decomposition templates, delegates each child
|
|
5
|
+
through the transport-neutral ``delegate`` seam, and on a decomposable failure
|
|
6
|
+
re-splits that child at finer granularity. It does not know about MCP, stdio or
|
|
7
|
+
the CLI, and never talks to the model directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from .atomizer import TaskBudget, decompose
|
|
17
|
+
from .service import DelegationRequest
|
|
18
|
+
from .task import TaskEnvelope
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Delegate = Callable[[str, DelegationRequest], Mapping[str, Any]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class DecompositionTemplate:
|
|
26
|
+
"""A named strategy that turns one task envelope into bounded children."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
split: Callable[[TaskEnvelope, TaskBudget], tuple[TaskEnvelope, ...]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _split_by_files(task: TaskEnvelope, budget: TaskBudget) -> tuple[TaskEnvelope, ...]:
|
|
33
|
+
# ponytail: reuse atomizer.decompose; a context/checks-over-budget task is
|
|
34
|
+
# not file-splittable, so fall back to the unchanged envelope and let the
|
|
35
|
+
# delegate reject it with a machine-readable reason.
|
|
36
|
+
try:
|
|
37
|
+
return decompose(task, budget).children
|
|
38
|
+
except ValueError:
|
|
39
|
+
return (task,)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _split_per_file(task: TaskEnvelope, budget: TaskBudget) -> tuple[TaskEnvelope, ...]:
|
|
43
|
+
del budget
|
|
44
|
+
return tuple(
|
|
45
|
+
TaskEnvelope(
|
|
46
|
+
id=f"{task.id}#{index + 1}",
|
|
47
|
+
goal=task.goal,
|
|
48
|
+
files=(file,),
|
|
49
|
+
context=task.context,
|
|
50
|
+
constraints=task.constraints,
|
|
51
|
+
checks=task.checks,
|
|
52
|
+
acceptance=task.acceptance,
|
|
53
|
+
)
|
|
54
|
+
for index, file in enumerate(task.files)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
BY_FILES = DecompositionTemplate("by_files", _split_by_files)
|
|
59
|
+
PER_FILE = DecompositionTemplate("per_file", _split_per_file)
|
|
60
|
+
DEFAULT_TEMPLATES = (BY_FILES, PER_FILE)
|
|
61
|
+
|
|
62
|
+
_DECOMPOSABLE_KINDS = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"preflight_rejected",
|
|
65
|
+
"context_limit",
|
|
66
|
+
"max_turns",
|
|
67
|
+
"retry_budget_exhausted",
|
|
68
|
+
"too_many_files",
|
|
69
|
+
"too_many_checks",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_decomposable_failure(result: Mapping[str, Any]) -> bool:
|
|
75
|
+
"""True when a child failure can be retried at finer granularity."""
|
|
76
|
+
|
|
77
|
+
error = result.get("error")
|
|
78
|
+
if not isinstance(error, Mapping):
|
|
79
|
+
return False
|
|
80
|
+
kind = error.get("kind")
|
|
81
|
+
if kind in _DECOMPOSABLE_KINDS:
|
|
82
|
+
return True
|
|
83
|
+
if kind == "controller_policy":
|
|
84
|
+
message = str(error.get("message", ""))
|
|
85
|
+
return "max_files" in message or "context" in message
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DelegatingAgent:
|
|
90
|
+
"""Decompose a task into bounded children and delegate each one.
|
|
91
|
+
|
|
92
|
+
``delegate`` is the harness-agnostic seam (e.g. ``DelegationService.delegate``)
|
|
93
|
+
with signature ``(caller_id, request) -> result``. ``workspace_ref`` and
|
|
94
|
+
``model_profile`` are deployment configuration, not part of the task.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
delegate: Delegate,
|
|
100
|
+
*,
|
|
101
|
+
workspace_ref: str,
|
|
102
|
+
model_profile: str,
|
|
103
|
+
budget: TaskBudget | None = None,
|
|
104
|
+
templates: Sequence[DecompositionTemplate] = DEFAULT_TEMPLATES,
|
|
105
|
+
max_depth: int = 3,
|
|
106
|
+
max_parallel_children: int = 1,
|
|
107
|
+
) -> None:
|
|
108
|
+
if max_depth <= 0:
|
|
109
|
+
raise ValueError("max_depth must be positive")
|
|
110
|
+
if not templates:
|
|
111
|
+
raise ValueError("templates must not be empty")
|
|
112
|
+
if max_parallel_children <= 0:
|
|
113
|
+
raise ValueError("max_parallel_children must be positive")
|
|
114
|
+
self.delegate = delegate
|
|
115
|
+
self.workspace_ref = workspace_ref
|
|
116
|
+
self.model_profile = model_profile
|
|
117
|
+
self.budget = budget or TaskBudget()
|
|
118
|
+
self.templates = tuple(templates)
|
|
119
|
+
self.max_depth = max_depth
|
|
120
|
+
self.max_parallel_children = max_parallel_children
|
|
121
|
+
|
|
122
|
+
def run(self, caller_id: str, task: TaskEnvelope) -> dict[str, Any]:
|
|
123
|
+
if not isinstance(caller_id, str) or not caller_id.strip():
|
|
124
|
+
raise ValueError("caller_id must be a non-empty string")
|
|
125
|
+
splits = [0]
|
|
126
|
+
leaves = self._dispatch(caller_id, task, 0, splits)
|
|
127
|
+
accepted = sum(1 for leaf in leaves if leaf["result"].get("status") == "accepted")
|
|
128
|
+
if accepted == len(leaves):
|
|
129
|
+
status = "accepted"
|
|
130
|
+
elif accepted:
|
|
131
|
+
status = "partial"
|
|
132
|
+
else:
|
|
133
|
+
status = "failed"
|
|
134
|
+
return {
|
|
135
|
+
"status": status,
|
|
136
|
+
"task_id": task.id,
|
|
137
|
+
"splits": splits[0],
|
|
138
|
+
"children": leaves,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def _dispatch(
|
|
142
|
+
self,
|
|
143
|
+
caller_id: str,
|
|
144
|
+
task: TaskEnvelope,
|
|
145
|
+
depth: int,
|
|
146
|
+
splits: list[int],
|
|
147
|
+
) -> list[dict[str, Any]]:
|
|
148
|
+
template = self.templates[min(depth, len(self.templates) - 1)]
|
|
149
|
+
children = template.split(task, self.budget)
|
|
150
|
+
leaves: list[dict[str, Any]] = []
|
|
151
|
+
|
|
152
|
+
def run_child(child: TaskEnvelope, index: int) -> tuple[dict[str, Any], bool]:
|
|
153
|
+
request = DelegationRequest(
|
|
154
|
+
request_id=f"{task.id}@{depth}.{index}",
|
|
155
|
+
workspace_ref=self.workspace_ref,
|
|
156
|
+
model_profile=self.model_profile,
|
|
157
|
+
task=child,
|
|
158
|
+
)
|
|
159
|
+
result = self.delegate(caller_id, request)
|
|
160
|
+
return dict(result), is_decomposable_failure(result) and self._can_split_further(child, depth)
|
|
161
|
+
|
|
162
|
+
if self.max_parallel_children > 1 and len(children) > 1:
|
|
163
|
+
with ThreadPoolExecutor(max_workers=self.max_parallel_children) as pool:
|
|
164
|
+
outcomes = list(pool.map(lambda item: run_child(item[1], item[0]), enumerate(children)))
|
|
165
|
+
else:
|
|
166
|
+
outcomes = [run_child(child, index) for index, child in enumerate(children)]
|
|
167
|
+
|
|
168
|
+
for child, (result, decomposable) in zip(children, outcomes):
|
|
169
|
+
if decomposable:
|
|
170
|
+
splits[0] += 1
|
|
171
|
+
leaves.extend(self._dispatch(caller_id, child, depth + 1, splits))
|
|
172
|
+
else:
|
|
173
|
+
leaves.append({"task_id": child.id, "depth": depth, "result": result})
|
|
174
|
+
return leaves
|
|
175
|
+
|
|
176
|
+
def _can_split_further(self, task: TaskEnvelope, depth: int) -> bool:
|
|
177
|
+
if depth + 1 >= self.max_depth:
|
|
178
|
+
return False
|
|
179
|
+
next_template = self.templates[min(depth + 1, len(self.templates) - 1)]
|
|
180
|
+
return len(next_template.split(task, self.budget)) > 1
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Desktop application launcher using pywebview with resilient browser fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .server import DesktopServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def launch_desktop_app(
|
|
16
|
+
host: str = "127.0.0.1",
|
|
17
|
+
port: int = 8765,
|
|
18
|
+
workspace: str | Path = ".",
|
|
19
|
+
default_profile: str = "qwen2.5-coder",
|
|
20
|
+
browser: bool = False,
|
|
21
|
+
headless: bool = False,
|
|
22
|
+
) -> int:
|
|
23
|
+
"""Launch the Desktop AI Coding Harness."""
|
|
24
|
+
server = DesktopServer(
|
|
25
|
+
host=host,
|
|
26
|
+
port=port,
|
|
27
|
+
workspace=workspace,
|
|
28
|
+
default_profile=default_profile,
|
|
29
|
+
)
|
|
30
|
+
server.start()
|
|
31
|
+
app_url = f"{server.url}/app"
|
|
32
|
+
|
|
33
|
+
if headless:
|
|
34
|
+
print(
|
|
35
|
+
json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"status": "ready",
|
|
38
|
+
"url": app_url,
|
|
39
|
+
"workspace": str(Path(workspace).resolve()),
|
|
40
|
+
},
|
|
41
|
+
ensure_ascii=False,
|
|
42
|
+
),
|
|
43
|
+
flush=True,
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
while True:
|
|
47
|
+
time.sleep(1)
|
|
48
|
+
except KeyboardInterrupt:
|
|
49
|
+
pass
|
|
50
|
+
finally:
|
|
51
|
+
server.stop()
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
print("=" * 72)
|
|
55
|
+
print(" Local AI Coding Harness — Standalone Desktop Cockpit")
|
|
56
|
+
print(f" Interface URL: {app_url}")
|
|
57
|
+
print(f" Workspace: {Path(workspace).resolve()}")
|
|
58
|
+
print("=" * 72)
|
|
59
|
+
|
|
60
|
+
has_webview = False
|
|
61
|
+
if not browser:
|
|
62
|
+
try:
|
|
63
|
+
import webview # type: ignore[import-not-found]
|
|
64
|
+
has_webview = True
|
|
65
|
+
except ImportError:
|
|
66
|
+
has_webview = False
|
|
67
|
+
|
|
68
|
+
if has_webview and not browser:
|
|
69
|
+
try:
|
|
70
|
+
import webview # type: ignore[import-not-found]
|
|
71
|
+
|
|
72
|
+
print("Launching native desktop window (WebView2 / Cocoa / WebKit)...")
|
|
73
|
+
webview.create_window(
|
|
74
|
+
"Local AI Coding Harness",
|
|
75
|
+
app_url,
|
|
76
|
+
width=1360,
|
|
77
|
+
height=840,
|
|
78
|
+
min_size=(960, 640),
|
|
79
|
+
)
|
|
80
|
+
webview.start()
|
|
81
|
+
server.stop()
|
|
82
|
+
print("Desktop window closed.")
|
|
83
|
+
return 0
|
|
84
|
+
except Exception as error:
|
|
85
|
+
print(f"Native desktop window initialization error: {error}. Falling back to default browser...")
|
|
86
|
+
|
|
87
|
+
# Fallback to system browser
|
|
88
|
+
print("Opening application in default web browser (Press Ctrl+C to stop)...")
|
|
89
|
+
try:
|
|
90
|
+
webbrowser.open(app_url)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
while True:
|
|
96
|
+
time.sleep(1)
|
|
97
|
+
except KeyboardInterrupt:
|
|
98
|
+
server.stop()
|
|
99
|
+
print("\nDesktop Harness stopped.")
|
|
100
|
+
return 0
|