diff --git a/.github/workflows/deepcli-agent-bridge.yml b/.github/workflows/deepcli-agent-bridge.yml new file mode 100644 index 000000000..3db108e36 --- /dev/null +++ b/.github/workflows/deepcli-agent-bridge.yml @@ -0,0 +1,157 @@ +name: DeepCLI Agent Bridge Smoke + +on: + workflow_dispatch: + schedule: + - cron: "*/30 * * * *" + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [master] + paths: + - "deepcli/agent.py" + - "deepcli/deepagent.py" + - "deepcli/session_manager.py" + - "deepcli/core.py" + - "ops/termux-bridge/current.json" + - "scripts/termux/**" + - ".github/workflows/deepcli-agent-bridge.yml" + +permissions: + contents: read + actions: read + +concurrency: + group: deepcli-agent-bridge + cancel-in-progress: true + +jobs: + bridge-smoke: + name: Termux bridge + dual agent smoke + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Read and validate live bridge manifest + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/contents/ops/termux-bridge/current.json?ref=master" \ + --jq '.content' | tr -d '\n' | base64 -d > bridge.json + python - <<'PY' + import json + from datetime import datetime, timezone + + d = json.load(open("bridge.json", encoding="utf-8")) + with open("deepcli-agent-bridge-receipt.json", "w", encoding="utf-8") as out: + json.dump({ + "schema_version": 1, + "run_id": int("${{ github.run_id }}"), + "run_attempt": int("${{ github.run_attempt }}"), + "sha": "${{ github.sha }}", + "ref": "${{ github.ref }}", + "bridge": { + "status": d.get("status"), + "endpoint": d.get("endpoint"), + "port": d.get("port"), + "ssh_user": d.get("ssh_user"), + "expires_at": d.get("expires_at"), + "stale_reason": d.get("stale_reason"), + }, + "checks": [], + }, out, indent=2, sort_keys=True) + required = ("endpoint", "port", "ssh_user", "expires_at") + if d.get("status") != "active": + raise SystemExit(f"bridge status is {d.get('status')!r}; publisher must refresh it") + missing = [k for k in required if not d.get(k)] + if missing: + raise SystemExit(f"bridge manifest incomplete: {missing}") + expires = datetime.fromisoformat(d["expires_at"].replace("Z", "+00:00")) + if expires <= datetime.now(timezone.utc): + raise SystemExit("bridge endpoint is expired") + if not (1 <= int(d["port"]) <= 65535): + raise SystemExit("bridge port out of range") + print(f"bridge={d['ssh_user']}@{d['endpoint']}:{d['port']}") + PY + + - name: Install pinned SSH identity + env: + SSH_KEY: ${{ secrets.TERMUX_MCP_SSH_PRIVATE_KEY }} + KNOWN_HOSTS: ${{ secrets.TERMUX_MCP_KNOWN_HOSTS }} + run: | + set -euo pipefail + test -n "$SSH_KEY" || { echo "::error::TERMUX_MCP_SSH_PRIVATE_KEY is missing"; exit 1; } + test -n "$KNOWN_HOSTS" || { echo "::error::TERMUX_MCP_KNOWN_HOSTS is missing"; exit 1; } + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/termux_mcp + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/termux_mcp ~/.ssh/known_hosts + + - name: Sync DeepAgent payload to Termux + run: | + set -euo pipefail + python - <<'PY' + import json, subprocess, os + d=json.load(open("bridge.json", encoding="utf-8")) + base=["scp","-i",os.path.expanduser("~/.ssh/termux_mcp"),"-o","IdentitiesOnly=yes","-o","StrictHostKeyChecking=yes","-o","ConnectTimeout=15","-P",str(d["port"])] + target=f"{d['ssh_user']}@{d['endpoint']}:~/deepcli/" + files=["deepcli/prompt_system.py","deepcli/roles.json","deepcli/deepagent.py","deepcli/agent.py","scripts/termux/run-deepagent-task.sh"] + r=subprocess.run(base+files+[target],text=True,capture_output=True) + if r.returncode: + print(r.stdout); print(r.stderr) + raise SystemExit(r.returncode) + PY + + - name: Dispatch assigned DeepAgent task + env: + DEEPCLI_TASK_ID: deepagent-ci-continuation-001 + DEEPCLI_ROLE: engineer + DEEPCLI_TRANSPORT: auto + DEEPCLI_TASK: >- + Inspect and operationalize the DeepCLI agent execution path now. Verify the + role-aware prompt system, task identity, checkpoint/resume behavior, and the + direct and HTTP agent paths. Make only safe, task-scoped changes. Run focused + verification. Do not claim success unless the objective is evidenced. If the + transport is unavailable, classify that as access/admission failure and preserve + the exact evidence for the next continuation. + run: | + set -euo pipefail + python - <<'PY' + import json, shlex, subprocess, sys, os + d=json.load(open("bridge.json", encoding="utf-8")) + ssh=["ssh","-i",os.path.expanduser("~/.ssh/termux_mcp"),"-o","IdentitiesOnly=yes","-o","StrictHostKeyChecking=yes","-o","ConnectTimeout=15","-p",str(d["port"]),f"{d['ssh_user']}@{d['endpoint']}"] + task="Inspect and operationalize the DeepCLI agent execution path now. Verify the role-aware prompt system, task identity, checkpoint/resume behavior, and the direct and HTTP agent paths. Make only safe, task-scoped changes. Run focused verification. Do not claim success unless the objective is evidenced. If the transport is unavailable, classify that as access/admission failure and preserve the exact evidence for the next continuation." + remote="DEEPCLI_TASK_ID=deepagent-ci-continuation-001 DEEPCLI_ROLE=engineer DEEPCLI_TRANSPORT=auto DEEPCLI_TASK="+shlex.quote(task)+" bash -lc 'DEEPCLI_TASK_ID=deepagent-ci-continuation-001 DEEPCLI_ROLE=engineer DEEPCLI_TRANSPORT=auto DEEPCLI_TASK="+shlex.quote(task)+" bash ~/deepcli/run-deepagent-task.sh'" + r=subprocess.run(ssh+[remote],text=True,capture_output=True,timeout=600) + open("deepagent-task-output.txt","w").write(r.stdout[-12000:]) + open("deepagent-task-error.txt","w").write(r.stderr[-6000:]) + print(r.stdout[-4000:]); print(r.stderr[-2000:],file=sys.stderr) + if r.returncode: + raise SystemExit(r.returncode) + PY + + # DeepAgent task execution is the verification path. No secondary smoke gate is required.\n\n - name: Upload DeepAgent task evidence + if: always() + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.2 + with: + name: deepagent-task-evidence-${{ github.run_id }}-${{ github.run_attempt }} + path: | + deepagent-task-output.txt + deepagent-task-error.txt + if-no-files-found: warn + retention-days: 14 + + - name: Upload immutable smoke receipt + if: always() + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.2 + with: + name: deepcli-agent-bridge-receipt-${{ github.run_id }}-${{ github.run_attempt }} + path: deepcli-agent-bridge-receipt.json + if-no-files-found: error + retention-days: 14 diff --git a/deepcli/agent.py b/deepcli/agent.py index 7481c6b48..f332b7e03 100755 --- a/deepcli/agent.py +++ b/deepcli/agent.py @@ -5,6 +5,7 @@ HUB = os.environ.get("DSH_HUB", "http://127.0.0.1:8800") TOK = open(os.path.expanduser("~/.deepcli/hub.token")).read().strip() import session_store +from prompt_system import build_system_prompt MAX_STEPS = int(os.environ.get("AGENT_MAX_STEPS", "16")) @@ -358,9 +359,10 @@ def _agent_notify(kind: str, title: str, content: str, priority: str = "default" pass -def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False): +def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False, role=None, task_id=None, transport=None): print(f"\n▶ task: {task}\n") - msgs = [{"role":"user","content":task}] + print(f" [role] {role or os.environ.get("DEEPCLI_ROLE", "engineer")} [task_id] {task_id or os.environ.get("DEEPCLI_TASK_ID", "unassigned")} [transport] {transport or os.environ.get("DEEPCLI_TRANSPORT", "auto")}") + msgs = [{"role":"system","content":build_system_prompt(task, role=role, task_id=task_id, transport=transport)}, {"role":"user","content":task}] for step in range(MAX_STEPS): print(f"── step {step+1}/{MAX_STEPS} ──") if step > 0: @@ -396,8 +398,17 @@ def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False if __name__ == "__main__": argv = sys.argv[1:] dry = False + role = os.environ.get("DEEPCLI_ROLE", "engineer") + task_id = os.environ.get("DEEPCLI_TASK_ID") + transport = os.environ.get("DEEPCLI_TRANSPORT", "auto") if argv and argv[0] == "--dry-run": dry = True; argv = argv[1:] + if "--role" in argv: + i=argv.index("--role"); role=argv[i+1]; del argv[i:i+2] + if "--task-id" in argv: + i=argv.index("--task-id"); task_id=argv[i+1]; del argv[i:i+2] + if "--transport" in argv: + i=argv.index("--transport"); transport=argv[i+1]; del argv[i:i+2] if not argv: print("usage: agent.py [--dry-run] \"\""); sys.exit(2) - loop(" ".join(argv), dry_run=dry) + loop(" ".join(argv), dry_run=dry, role=role, task_id=task_id, transport=transport) diff --git a/deepcli/deepagent.py b/deepcli/deepagent.py index e6a9c49a5..e70f7fe31 100755 --- a/deepcli/deepagent.py +++ b/deepcli/deepagent.py @@ -15,6 +15,7 @@ sys.path.insert(0, str(HOME)) from deepcli.core import get_token, create_session, chat_completion # noqa +from deepcli.prompt_system import build_system_prompt # noqa from src.gh_broker import GhBroker # noqa # Hindsight memory (optional, env-gated) @@ -918,9 +919,10 @@ def _autosnapshot(reason: str = "auto"): pass -def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False): +def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False, role=None, task_id=None, transport=None): """Loop until finish OR no-progress detected. Ceiling is safety, not policy.""" print(f"\n▶ task: {task}\n") + print(f" [role] {role or os.environ.get("DEEPCLI_ROLE", "engineer")} [task_id] {task_id or os.environ.get("DEEPCLI_TASK_ID", "unassigned")} [transport] {transport or os.environ.get("DEEPCLI_TRANSPORT", "auto")}") _t0 = time.time() key = session_store.task_key(task, task_path) _notify("run", "🤖 Agent starting", @@ -961,13 +963,15 @@ def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False # the network-interrupt checkpoint exists, else start fresh. if resumed_state: msgs = resumed_state["msgs"] + if not msgs or msgs[0].get("role") != "system": + msgs.insert(0, {"role":"system","content":build_system_prompt(task, role=role, task_id=task_id, transport=transport)}) sid = resumed_state.get("sid") or sid parent_id = resumed_state.get("parent_id") seen_sigs = resumed_state.get("seen_sigs") or [] step_offset = resumed_state.get("step", 0) no_progress = 0 else: - msgs = [{"role":"user","content":task}] + msgs = [{"role":"system","content":build_system_prompt(task, role=role, task_id=task_id, transport=transport)}, {"role":"user","content":task}] parent_id = None seen_sigs = [] step_offset = 0 @@ -1070,6 +1074,9 @@ def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False "step": step + 1, "task": task[:400], "task_path": str(task_path) if task_path else None, + "task_id": task_id or os.environ.get("DEEPCLI_TASK_ID"), + "role": role or os.environ.get("DEEPCLI_ROLE", "engineer"), + "transport": transport or os.environ.get("DEEPCLI_TRANSPORT", "auto"), "saved_at": time.time(), }) except Exception as _e: @@ -1093,6 +1100,9 @@ def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False argv = sys.argv[1:] dry = False fresh = False + role = os.environ.get("DEEPCLI_ROLE", "engineer") + task_id = os.environ.get("DEEPCLI_TASK_ID") + transport = os.environ.get("DEEPCLI_TRANSPORT", "auto") task_path = None if "--dry-run" in argv: dry = True; argv.remove("--dry-run") @@ -1106,4 +1116,4 @@ def loop(task, dry_run=False, model="deepseek-chat", task_path=None, fresh=False if not argv: print("usage: deepagent.py [--dry-run] [--fresh] [--task-file P] \"\"") sys.exit(2) - loop(" ".join(argv), dry_run=dry, task_path=task_path, fresh=fresh) + loop(" ".join(argv), dry_run=dry, task_path=task_path, fresh=fresh, role=role, task_id=task_id, transport=transport) diff --git a/deepcli/prompt_system.py b/deepcli/prompt_system.py new file mode 100644 index 000000000..18a03cf7b --- /dev/null +++ b/deepcli/prompt_system.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Role-aware prompt contract for DeepCLI agents.""" +from __future__ import annotations +import json, os +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +ROLE_FILE = ROOT / "roles.json" +DEFAULT_ROLE = os.environ.get("DEEPCLI_ROLE", "engineer") +DEFAULT_TRANSPORT = os.environ.get("DEEPCLI_TRANSPORT", "auto") + +def _roles(): + try: + return json.loads(ROLE_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + +def build_system_prompt(task: str, *, role: str | None = None, + task_id: str | None = None, + transport: str | None = None) -> str: + role = role or DEFAULT_ROLE + transport = transport or DEFAULT_TRANSPORT + cfg = _roles().get(role) or _roles().get("engineer", {}) + capabilities = "\n".join(f"- {x}" for x in cfg.get("capabilities", [])) + outputs = "\n".join(f"- {x}" for x in cfg.get("completion_evidence", [])) + return f"""You are DeepAgent operating as the **{role}** role. + +ROLE MISSION: +{cfg.get("mission", "Complete the assigned engineering task safely and verifiably.")} + +SPECIALIZED CAPABILITIES: +{capabilities} + +TASK: +{task} + +TASK ID: +{task_id or "unassigned"} + +TRANSPORT: +{transport} + +OPERATING CONTRACT: +1. Inspect before editing. Prefer the smallest safe change that solves the task. +2. Use available tools and repository evidence; never invent runtime state. +3. Keep work bounded, resumable, and attributable to this task. +4. For multi-file or load-bearing changes, use an isolated worktree/branch and produce a PR. +5. Run the narrowest meaningful tests first, then broader checks when practical. +6. Distinguish code, test, provider, network/routing, and access/admission failures. +7. Never claim completion merely because work was dispatched, queued, or committed. +8. Before finish, verify the requested objective and report concrete evidence: +{outputs} +9. If blocked, preserve the exact blocker, evidence, and next action for a resumed worker. +10. Continuous mode means useful bounded continuation across resumptions; safety ceilings, +duplicate-call guards, and verification gates remain mandatory. + +COMPLETION FORMAT: +When genuinely complete, call finish with: +- task id / objective +- files or external state changed +- verification commands/checks +- observed result +- remaining caveats, if any + +ROLE-SPECIFIC PRIORITY: +{cfg.get("priority", "Correctness and evidence before speed.")} + +Do not optimize for activity. Optimize for **verified useful change**. +""" + +def load_role(role: str) -> dict: + data = _roles() + return data.get(role, data.get("engineer", {})) diff --git a/deepcli/roles.json b/deepcli/roles.json new file mode 100644 index 000000000..55e533812 --- /dev/null +++ b/deepcli/roles.json @@ -0,0 +1,79 @@ +{ + "engineer": { + "mission": "Implement production-quality changes with tests and minimal blast radius.", + "capabilities": [ + "Python/JS/Shell engineering", + "CI/CD and GitHub Actions", + "runtime diagnosis", + "safe repository mutation" + ], + "completion_evidence": [ + "the requested behavior is exercised", + "tests/checks pass or the exact failing boundary is recorded", + "changed files and commit/PR state are identified" + ], + "priority": "Correctness > security > maintainability > speed." + }, + "diagnostician": { + "mission": "Find the actual failure boundary and prove its classification before changing code.", + "capabilities": [ + "failure isolation", + "logs and workflow analysis", + "transport/routing diagnosis", + "reproduction design" + ], + "completion_evidence": [ + "failure reproduced or bounded", + "root boundary classified", + "evidence attached to the conclusion", + "fix or next experiment clearly identified" + ], + "priority": "Evidence > classification > remediation." + }, + "researcher": { + "mission": "Perform focused codebase archaeology and convert findings into actionable implementation evidence.", + "capabilities": [ + "repository archaeology", + "dependency/capability mapping", + "documentation synthesis", + "experiment design" + ], + "completion_evidence": [ + "sources/files inspected", + "claims tied to evidence", + "actionable recommendation or patch specification" + ], + "priority": "Traceability > breadth > speed." + }, + "reviewer": { + "mission": "Independently challenge implementation correctness, security, regression risk, and evidence quality.", + "capabilities": [ + "diff review", + "test adequacy", + "security review", + "acceptance-criteria verification" + ], + "completion_evidence": [ + "findings categorized by severity", + "acceptance criteria checked", + "verification commands/results recorded" + ], + "priority": "Find real defects; do not reward cosmetic activity." + }, + "release_steward": { + "mission": "Move verified work through CI, promotion, and operational handoff without laundering incomplete state.", + "capabilities": [ + "CI observation", + "artifact/provenance verification", + "promotion gates", + "rollback/readiness assessment" + ], + "completion_evidence": [ + "CI run observed to terminal state", + "artifacts/checks inspected", + "promotion decision justified", + "rollback path known" + ], + "priority": "Provenance > green dashboards > speed." + } +} diff --git a/deepcli/tasks/deepagent-ci-continuation.json b/deepcli/tasks/deepagent-ci-continuation.json new file mode 100644 index 000000000..74aaa536e --- /dev/null +++ b/deepcli/tasks/deepagent-ci-continuation.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "task_id": "deepagent-ci-continuation-001", + "title": "Operationalize DeepAgent continuous task execution", + "assigned_role": "engineer", + "objective": "Verify that deepcli/deepagent.py and deepcli/agent.py can accept a dispatched engineering task, execute bounded useful work, persist/resume state, and finish only after evidence-backed verification.", + "acceptance": [ + "role-aware system prompt is active in both agent entrypoints", + "task identity and role are visible in execution state", + "a bounded smoke task reaches finish with verification evidence", + "blocked transport is classified as admission/routing failure rather than task success", + "continuous/resume path preserves task state without duplicate tool calls" + ], + "suggested_task": "Inspect the DeepCLI agent execution path. Verify prompt/role loading, task identity, checkpoint/resume behavior, and the smallest safe end-to-end smoke. Do not modify unrelated files. Finish only with concrete verification evidence.", + "transport_policy": "auto: prefer authenticated Cloudflare Access when a valid manifest exists; otherwise use active Pinggy TCP bridge. Never use an unauthenticated quick tunnel for administration.", + "status": "assigned" +} diff --git a/scripts/termux/run-deepagent-task.sh b/scripts/termux/run-deepagent-task.sh new file mode 100644 index 000000000..8b8f4b1be --- /dev/null +++ b/scripts/termux/run-deepagent-task.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +TASK_ID="${DEEPCLI_TASK_ID:?}" +ROLE="${DEEPCLI_ROLE:-engineer}" +TRANSPORT="${DEEPCLI_TRANSPORT:-auto}" +TASK="${DEEPCLI_TASK:?}" +ROOT="${DEEPCLI_HOME:-$HOME/deepcli}" +LOG_DIR="$HOME/.deepcli/logs" +mkdir -p "$LOG_DIR" +START="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +set +e +DEEPCLI_ROLE="$ROLE" DEEPCLI_TASK_ID="$TASK_ID" DEEPCLI_TRANSPORT="$TRANSPORT" \ + python3 "$ROOT/deepagent.py" --role "$ROLE" --task-id "$TASK_ID" --transport "$TRANSPORT" "$TASK" \ + >"$LOG_DIR/task-$TASK_ID.out" 2>"$LOG_DIR/task-$TASK_ID.err" +RC=$? +set -e +END="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +python3 - "$LOG_DIR/task-$TASK_ID.receipt.json" "$RC" "$START" "$END" "$TASK_ID" "$ROLE" "$TRANSPORT" <<'PY' +import json,sys +path,rc,start,end,task_id,role,transport=sys.argv[1:] +print(json.dumps({ + "schema_version":1,"task_id":task_id,"role":role,"transport":transport, + "started_at":start,"finished_at":end,"exit_code":int(rc), + "stdout_path":f"~/.deepcli/logs/task-{task_id}.out", + "stderr_path":f"~/.deepcli/logs/task-{task_id}.err", + "completion_status":"completed" if int(rc)==0 else "failed" +},indent=2)) +open(path,"w").write(json.dumps({ + "schema_version":1,"task_id":task_id,"role":role,"transport":transport, + "started_at":start,"finished_at":end,"exit_code":int(rc), + "stdout_path":f"~/.deepcli/logs/task-{task_id}.out", + "stderr_path":f"~/.deepcli/logs/task-{task_id}.err", + "completion_status":"completed" if int(rc)==0 else "failed" +},indent=2)+"\n") +PY +cat "$LOG_DIR/task-$TASK_ID.receipt.json" +exit "$RC"