diff --git a/.github/actions/agent-context-store/action.yml b/.github/actions/agent-context-store/action.yml deleted file mode 100644 index b90b197ea..000000000 --- a/.github/actions/agent-context-store/action.yml +++ /dev/null @@ -1,208 +0,0 @@ -name: 'Agent Context Store' -description: > - Load / save durable *work context* for agentic GitHub Actions runs. - Never stores tokens, cookies, PoW answers, or raw conversation bodies. - Auth sessions stay in $RUNNER_TEMP and are discarded (see #112/#114 policy). - -inputs: - mode: - description: 'load | save' - required: true - context_key: - description: > - Stable key for this work unit (e.g. pr-123-review, issue-118-triage). - Used as Actions cache key suffix. - required: true - context_json: - description: 'JSON string to save (mode=save). Must be non-secret fields only.' - required: false - default: '{}' - github_token: - description: 'Token for optional comment-marker fallback (GITHUB_TOKEN is fine)' - required: false - default: '' - target_number: - description: 'PR or issue number for comment-marker bookmark (optional)' - required: false - default: '' - repository: - description: 'owner/repo' - required: false - default: '' - -outputs: - context_json: - description: 'Loaded context JSON (mode=load); empty object if miss or mode=save' - value: ${{ steps.load.outputs.context_json || '{}' }} - cache_hit: - description: 'true if a previous context file was restored (exact or via restore-keys)' - value: ${{ steps.hit.outputs.cache_hit || 'false' }} - -runs: - using: composite - steps: - - name: Resolve cache paths - id: paths - shell: bash - env: - CONTEXT_KEY: ${{ inputs.context_key }} - MODE: ${{ inputs.mode }} - run: | - set -euo pipefail - if [[ "$MODE" != "load" && "$MODE" != "save" ]]; then - echo "::error::mode must be 'load' or 'save' (got: $MODE)" - exit 1 - fi - CTX_DIR="${RUNNER_TEMP}/agent-context" - mkdir -p "$CTX_DIR" - KEY_SAFE=$(printf '%s' "$CONTEXT_KEY" | tr -c 'A-Za-z0-9._-' '_') - KEY_HASH=$(printf '%s' "$CONTEXT_KEY" | sha256sum | cut -c1-8) - KEY_SAFE="${KEY_SAFE}-${KEY_HASH}" - echo "dir=${CTX_DIR}" >> "$GITHUB_OUTPUT" - echo "file=${CTX_DIR}/${KEY_SAFE}.json" >> "$GITHUB_OUTPUT" - echo "key_safe=${KEY_SAFE}" >> "$GITHUB_OUTPUT" - - - name: Restore context cache - id: cache - uses: actions/cache@v4 - with: - path: ${{ steps.paths.outputs.dir }} - key: agent-ctx-${{ runner.os }}-${{ steps.paths.outputs.key_safe }}-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - agent-ctx-${{ runner.os }}-${{ steps.paths.outputs.key_safe }}- - - - name: Detect restored context - id: hit - shell: bash - env: - FILE: ${{ steps.paths.outputs.file }} - run: | - set -euo pipefail - if [ -f "$FILE" ]; then - echo "cache_hit=true" >> "$GITHUB_OUTPUT" - else - echo "cache_hit=false" >> "$GITHUB_OUTPUT" - fi - - - name: Load context - id: load - if: inputs.mode == 'load' - shell: bash - env: - FILE: ${{ steps.paths.outputs.file }} - CONTEXT_KEY: ${{ inputs.context_key }} - run: | - set -euo pipefail - if [ -f "$FILE" ]; then - python3 - <<'PY' - import json, os - - def strip_forbidden(obj): - FORBIDDEN = {"token", "cookie", "cookies", "authorization", "api_key", - "pow_answer", "pow_signature", "password", "secret"} - if isinstance(obj, dict): - return { - k: strip_forbidden(v) - for k, v in obj.items() - if k.lower() not in FORBIDDEN - and not any(x in k.lower() for x in ("token", "cookie", "secret", "password", "pow_")) - } - if isinstance(obj, list): - return [strip_forbidden(i) for i in obj] - return obj - - path = os.environ["FILE"] - try: - with open(path) as f: - data = json.load(f) - except (json.JSONDecodeError, OSError) as e: - print(f"::warning::corrupt or unreadable context ({e}); discarding") - data = {} - if not isinstance(data, dict): - print("::warning::context is not an object; discarding") - data = {} - cleaned = strip_forbidden(data) - raw = json.dumps(cleaned, separators=(",", ":")) - if len(raw) > 32_000: - print("::warning::context truncated to 32k") - cleaned = {"_truncated": True, "keys": list(cleaned.keys())[:20]} - raw = json.dumps(cleaned, separators=(",", ":")) - with open(os.environ["GITHUB_OUTPUT"], "a") as out: - out.write(f"context_json={raw}\n") - print(f"::notice::Loaded context keys={list(cleaned.keys())}") - PY - else - echo 'context_json={}' >> "$GITHUB_OUTPUT" - echo "::notice::No prior context for key=${CONTEXT_KEY}" - fi - - - name: Save context - if: inputs.mode == 'save' - shell: bash - env: - INPUT_JSON: ${{ inputs.context_json }} - FILE: ${{ steps.paths.outputs.file }} - CONTEXT_KEY: ${{ inputs.context_key }} - run: | - set -euo pipefail - python3 - <<'PY' - import json, os, time - - def strip_forbidden(obj): - FORBIDDEN = {"token", "cookie", "cookies", "authorization", "api_key", - "pow_answer", "pow_signature", "password", "secret"} - if isinstance(obj, dict): - return { - k: strip_forbidden(v) - for k, v in obj.items() - if k.lower() not in FORBIDDEN - and not any(x in k.lower() for x in ("token", "cookie", "secret", "password", "pow_")) - } - if isinstance(obj, list): - return [strip_forbidden(i) for i in obj] - return obj - - raw_in = os.environ.get("INPUT_JSON") or "{}" - try: - data = json.loads(raw_in) - except json.JSONDecodeError: - data = {} - if not isinstance(data, dict): - data = {} - cleaned = strip_forbidden(data) - cleaned["_saved_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - cleaned["_key"] = os.environ.get("CONTEXT_KEY", "") - raw = json.dumps(cleaned, separators=(",", ":")) - if len(raw) > 32_000: - print("::warning::context exceeds 32k on save; truncating") - cleaned = {"_truncated": True, "_saved_at": cleaned["_saved_at"], "_key": cleaned["_key"], "keys": list(cleaned.keys())[:20]} - path = os.environ["FILE"] - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w") as f: - json.dump(cleaned, f, indent=2) - print(f"::notice::Saved agent context → {path} keys={list(cleaned.keys())}") - PY - - - name: Optional comment-marker bookmark - if: inputs.mode == 'save' && inputs.target_number != '' && inputs.github_token != '' - shell: bash - env: - GH_TOKEN: ${{ inputs.github_token }} - REPO: ${{ inputs.repository }} - TARGET: ${{ inputs.target_number }} - KEY: ${{ inputs.context_key }} - run: | - set -euo pipefail - if ! [[ "${TARGET:-}" =~ ^[0-9]+$ ]]; then exit 0; fi - if [ -z "${REPO:-}" ]; then REPO="${{ github.repository }}"; fi - MARKER="" - # gh api has no --arg; pipe to jq for safe filtering - EXISTING=$(gh api --paginate "repos/${REPO}/issues/${TARGET}/comments" 2>/dev/null \ - | jq -r --arg m "$MARKER" '.[] | select(.body | contains($m)) | .id' \ - | head -1 || true) - BODY=$(printf '%s\n_Context bookmark for `%s` (non-secret). Auth sessions remain ephemeral._\n' "$MARKER" "$KEY") - if [ -n "$EXISTING" ]; then - gh api -X PATCH "repos/${REPO}/issues/comments/${EXISTING}" -f body="$BODY" >/dev/null || true - else - gh api -X POST "repos/${REPO}/issues/${TARGET}/comments" -f body="$BODY" >/dev/null || true - fi diff --git a/.github/connectors/README.md b/.github/connectors/README.md deleted file mode 100644 index a594bc90f..000000000 --- a/.github/connectors/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Connector Catalog (AUTHORIZED) - -Centralized, non-secret configuration for external integrations. -Runtime code and Actions read **environment variable names** only — never secret values. - -## Authorized secrets (GitHub Actions / repo secrets) - -| Secret name | Purpose | Used by | -|-------------|---------|--------| -| `OPERATOR_GITHUB_TOKEN` | Write/admin PAT for CodeRabbit autofix, privileged comments, continuous-ops | peer-review-orchestrator, continuous-ops, auto-jules | -| `GITHUB_TOKEN` | Default Actions token (contents/PRs/issues scoped) | all workflows | -| `OMNI_API_KEY` / `OMNIROUTE_API_KEY` | OmniRoute free-tier peer | model-router, http-llm-invoke | -| `OMNI_BASE_URL` | Optional override (must be https allow-listed host) | http-llm-invoke | -| `OPENROUTER_API_KEY` | OpenRouter free-tier peer (`:free` models only) | model-router, http-llm-invoke | -| `GEMINI_API_KEY` | Gemini residual path | gemini-* workflows | -| Linear / Vercel / other | Connected via Grok Connectors or separate secrets as needed | agent-feedback-linear-sync, deploy | - -## Provider allow-list (http-llm-invoke) - -- `cloud.omniroute.online` -- `openrouter.ai` / `api.openrouter.ai` - -HTTPS only. Non-allow-listed hosts are rejected. - -## Soft-budget policy (exceed prior capacity) - -See `scripts/model_router.py` and `docs/schemas/model-success-matrix.yaml`. -Omni `auto/best-free` and OpenRouter free models have elevated daily soft limits -so peer capacity is preferred over Gemini residual. Limits are best-effort -(Actions cache per Pacific day). - -## Files in this directory - -- `github.yaml` — repo identity + API surface -- `llm-peers.yaml` — Omni / OpenRouter / Gemini routing preferences -- `integrations.yaml` — Linear, Vercel, Jules, CodeRabbit, Devin markers - -Exchange connectors (Yobit/KuCoin/Binance) were **intentionally removed** from -the active surface (PR #74 closed). Do not reintroduce without OPERATOR sign-off. diff --git a/.github/connectors/github.yaml b/.github/connectors/github.yaml deleted file mode 100644 index de09cf291..000000000 --- a/.github/connectors/github.yaml +++ /dev/null @@ -1,14 +0,0 @@ -github: - repository: - owner: timerloggedout-spec - name: termux-monorepo - api: - base_url: https://api.github.com - token_env: GITHUB_TOKEN - operator_token_env: OPERATOR_GITHUB_TOKEN - permissions_expected: - - contents: read|write - - pull-requests: write - - issues: write - - actions: read - - checks: read diff --git a/.github/connectors/integrations.yaml b/.github/connectors/integrations.yaml deleted file mode 100644 index 2b38f2b9c..000000000 --- a/.github/connectors/integrations.yaml +++ /dev/null @@ -1,19 +0,0 @@ -integrations: - coderabbit: - autofix_trigger: "@coderabbitai autofix" - requires_operator_token: true - jules: - primary_builder: true - async_cloud_vm: true - devin: - review_marker: true - linear: - workflow: agent-feedback-linear-sync.yml - vercel: - status_context: Vercel - peer_reviewers: - - coderabbit - - devin-ai - - aikido - - sentry - - copilot-pull-request diff --git a/.github/connectors/llm-peers.yaml b/.github/connectors/llm-peers.yaml deleted file mode 100644 index f9dbf52f7..000000000 --- a/.github/connectors/llm-peers.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# Peer policy: Omni \u2194 OpenRouter nearly equal free-tier peers. -# Selection = desired model + soft-budget headroom. Gemini = residual only. - -peers: - omni: - enabled_env: OMNI_API_KEY # or OMNIROUTE_API_KEY - base_url_env: OMNI_BASE_URL - default_base: https://cloud.omniroute.online/v1 - models: - - id: auto/best-free - roles: [triage, review, invoke] - soft_limits: - triage: 400 - review: 250 - invoke: 400 - openrouter: - enabled_env: OPENROUTER_API_KEY - default_base: https://openrouter.ai/api/v1 - require_free_suffix: true - models: - - id: google/gemma-4-31b-it:free - roles: [triage, review, invoke] - soft_limits: { triage: 80, review: 80, invoke: 80 } - - id: google/gemma-4-26b-a4b-it:free - roles: [triage, review, invoke] - soft_limits: { triage: 80, review: 80, invoke: 80 } - - id: meta-llama/llama-3.3-70b-instruct:free - roles: [triage, review, invoke] - soft_limits: { triage: 80, review: 80, invoke: 80 } - - id: google/gemma-3-12b-it:free - roles: [triage, review, invoke] - soft_limits: { triage: 80, review: 80, invoke: 80 } - - id: qwen/qwen3-coder:free - roles: [review, invoke] - soft_limits: { triage: 60, review: 60, invoke: 60 } - - id: deepseek/deepseek-r1:free - roles: [review] - soft_limits: { triage: 40, review: 40, invoke: 40 } - - id: cohere/north-mini-code:free - roles: [review, invoke] - soft_limits: { triage: 60, review: 60, invoke: 60 } - gemini: - enabled_env: GEMINI_API_KEY - residual_only: true - models: - - id: gemini-3.1-flash-lite - soft_limits: { triage: 450, review: 450, invoke: 450 } - - id: gemini-3.5-flash-lite - soft_limits: { triage: 450, review: 450, invoke: 450 } - - id: gemini-2.5-flash-lite - soft_limits: { triage: 20, review: 20, invoke: 20 } - - id: gemini-3.5-flash - soft_limits: { triage: 20, review: 20, invoke: 20 } - - id: gemini-2.5-flash - soft_limits: { triage: 20, review: 20, invoke: 20 } - - id: gemini-3-flash - soft_limits: { triage: 20, review: 20, invoke: 20 } diff --git a/.github/workflows/agent-continuous-ops.yml b/.github/workflows/agent-continuous-ops.yml deleted file mode 100644 index 5ece9aa90..000000000 --- a/.github/workflows/agent-continuous-ops.yml +++ /dev/null @@ -1,219 +0,0 @@ -name: Continuous agent ops - -# 24/7 unattended advancement of open agent PRs. -# Complements event-driven agent-review-auto-jules + peer-review-orchestrator: -# those fire on bot feedback; this catches PRs that went quiet, got dirty, -# or never received a follow-up after peer-review-ready. -# -# Requires: Jules GitHub App (repo access). Optional: secrets.JULES_API_KEY -# Security: no secrets in prompts; OPERATOR_TOKEN only for comment write if set. - -on: - schedule: - # Every 2 hours — enough to clear lag without burning review quota - - cron: '17 */2 * * *' - workflow_dispatch: - inputs: - force_all: - description: 'Ignore debounce and re-ping all eligible PRs' - required: false - default: 'false' - max_prs: - description: 'Max PRs to act on this run' - required: false - default: '8' - -concurrency: - group: continuous-agent-ops - cancel-in-progress: false - -permissions: {} - -jobs: - sweep: - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pull-requests: write - issues: write - steps: - - name: Sweep open PRs and advance stuck work - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - env: - FORCE_ALL: ${{ github.event.inputs.force_all || 'false' }} - MAX_PRS: ${{ github.event.inputs.max_prs || '8' }} - # Prefer operator token so @jules / @coderabbitai accept write-path comments - HAS_OPERATOR: ${{ secrets.OPERATOR_GITHUB_TOKEN != '' || secrets.OPERATOR_TOKEN != '' }} - with: - github-token: ${{ secrets.OPERATOR_GITHUB_TOKEN || secrets.OPERATOR_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const forceAll = process.env.FORCE_ALL === 'true'; - const maxPrs = Math.min(Number(process.env.MAX_PRS) || 8, 20); - const marker = ''; - const debounceMs = 90 * 60 * 1000; // 90 minutes - const staleMs = 3 * 60 * 60 * 1000; // 3 hours without agent activity - - const agentLogins = [ - 'google-labs-jules', 'devin-ai-integration', 'coderabbitai', - 'github-actions', 'copilot', 'gitar-bot', 'blocksorg', - ]; - const isAgent = (login) => { - const l = (login || '').toLowerCase(); - return agentLogins.some(a => l.includes(a)); - }; - - // List open non-draft PRs (newest activity first) - const prs = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - sort: 'updated', - direction: 'desc', - per_page: 50, - }); - - const eligible = prs.filter(p => !p.draft && !p.head.repo?.fork); - core.info(`Open non-draft PRs: ${eligible.length}`); - - let acted = 0; - const report = []; - - for (const pr of eligible) { - if (acted >= maxPrs) break; - - // Fresh mergeability - let mergeable = pr.mergeable; - let mergeableState = pr.mergeable_state; - try { - const { data: fresh } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - mergeable = fresh.mergeable; - mergeableState = fresh.mergeable_state; - } catch (e) { - core.info(`PR #${pr.number} get skip: ${e.message}`); - } - - // Comments for debounce + last agent activity - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, - }); - - const lastOps = comments - .filter(c => c.body && c.body.includes(marker)) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; - if (lastOps && !forceAll) { - const age = Date.now() - new Date(lastOps.created_at).getTime(); - if (age < debounceMs) { - core.info(`PR #${pr.number}: debounced (${Math.round(age/60000)}m ago)`); - continue; - } - } - - const lastAgentComment = comments - .filter(c => isAgent(c.user?.login)) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; - const lastAgentAge = lastAgentComment - ? Date.now() - new Date(lastAgentComment.created_at).getTime() - : Infinity; - - // Open review threads (unresolved) - let openThreads = 0; - try { - const q = ` - query($owner:String!, $repo:String!, $number:Int!) { - repository(owner:$owner, name:$repo) { - pullRequest(number:$number) { - reviewThreads(first: 50) { - nodes { isResolved } - } - } - } - }`; - const result = await github.graphql(q, { - owner: context.repo.owner, - repo: context.repo.repo, - number: pr.number, - }); - const nodes = result?.repository?.pullRequest?.reviewThreads?.nodes || []; - openThreads = nodes.filter(n => !n.isResolved).length; - } catch (e) { - core.info(`PR #${pr.number} threads skip: ${e.message}`); - } - - const dirty = mergeableState === 'dirty' || mergeable === false; - const blocked = mergeableState === 'blocked' || mergeableState === 'unstable'; - const stale = lastAgentAge > staleMs; - const needsWork = dirty || (blocked && openThreads > 0) || (stale && openThreads > 0); - - if (!needsWork && !forceAll) { - core.info(`PR #${pr.number}: clean enough (state=${mergeableState} threads=${openThreads})`); - continue; - } - - // Build action prompt - const reasons = []; - if (dirty) reasons.push('merge conflict / dirty vs base'); - if (blocked && openThreads > 0) reasons.push(`${openThreads} unresolved review thread(s)`); - if (stale) reasons.push(`stale agent activity (${Math.round(lastAgentAge/3600000)}h)`); - if (forceAll) reasons.push('forced sweep'); - - const instructions = []; - if (dirty) { - instructions.push('1. Rebase or merge base into head; resolve conflicts; push.'); - instructions.push('2. Prefer content-preserving resolution; cite Implements: if board item applies.'); - } - if (openThreads > 0) { - instructions.push(`${dirty ? '3' : '1'}. Address **all open review threads** (CodeRabbit, Devin, Copilot).`); - instructions.push(`${dirty ? '4' : '2'}. Prefer minimal diffs; preserve Sentinel 0o600/0o700.`); - } - instructions.push('Push commits to the existing head branch. Do not retarget base without cause.'); - instructions.push('Skip pure nits only if they conflict with security/gates.'); - instructions.push('If the PR is superseded, close it with a pointer to the replacement.'); - - const body = [ - marker, - `sha: ${pr.head.sha}`, - `state: ${mergeableState}`, - `threads_open: ${openThreads}`, - '', - `@jules **Continuous ops** (GHA agent-continuous-ops) — unattended advance.`, - '', - `PR #${pr.number} · \`${pr.head.ref}\` → \`${pr.base.ref}\``, - `**Why:** ${reasons.join('; ')}`, - '', - '### Instructions', - ...instructions, - '', - 'Read AGENTS.md. No Class 3/4 artifacts. No secret leaks.', - 'Agent: Grok orchestration · Profile: https://x.com/grok', - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body, - }); - - acted += 1; - report.push(`#${pr.number} (${reasons.join(', ')})`); - core.info(`PR #${pr.number}: nudged — ${reasons.join('; ')}`); - - // Brief pause to avoid secondary rate limits - await new Promise(r => setTimeout(r, 1500)); - } - - core.summary - .addHeading('Continuous agent ops') - .addRaw(`Acted on **${acted}** PR(s) this sweep.`) - .addList(report.length ? report : ['(none eligible)']) - .write(); - - core.setOutput('acted', String(acted)); diff --git a/.github/workflows/deepseek-ci.yml b/.github/workflows/deepseek-ci.yml new file mode 100644 index 000000000..1ae46f2eb --- /dev/null +++ b/.github/workflows/deepseek-ci.yml @@ -0,0 +1,72 @@ +name: DeepSeek CI – Agentic Automation + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + event_payload: + description: 'JSON event payload (optional)' + required: false + default: '{}' + +concurrency: + group: deepseek-ci-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: {} + +jobs: + deepseek-agent: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + actions: read + + env: + OPERATOR_TOKEN: ${{ secrets.OPERATOR_TOKEN }} + GITHUB_EVENT: ${{ toJson(github.event) }} + DEEPSEEK_WASM_PATH: ./deepcli/deepseek.wasm + + steps: + - name: Debounce rapid-succession commits + run: sleep 10 + + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + token: ${{ secrets.OPERATOR_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Setup Node.js (for PoW WASM) + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 + with: + node-version: '20' + + - name: Setup Python + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install Python dependencies + run: | + pip install curl_cffi requests pyyaml click + + - name: Run DeepSeek CI Agent + env: + PYTHONPATH: ${{ github.workspace }}/multi-ai-cli + run: | + python -m ci_mode \ + --workspace "$GITHUB_WORKSPACE" \ + --cache-dir "${{ runner.temp }}/deepseek-cache" + + - name: Upload results (if any) + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + if: always() + with: + name: deepseek-output + path: ./deepseek_output.json + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index ad7874842..66edcc5cc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ pipeline_log.jsonl *.db *.sqlite *.sqlite3 + +# === DeepSeek v4-Pro CI === +deepseek_output.json *.tar *.gz *.bz2 diff --git a/docs/ops/session-context-management.md b/docs/ops/session-context-management.md deleted file mode 100644 index a8f6746e3..000000000 --- a/docs/ops/session-context-management.md +++ /dev/null @@ -1,87 +0,0 @@ -# Session && Context Management (GitHub Actions) - -**Issue:** [#118](https://github.com/timerloggedout-spec/termux-monorepo/issues/118) -**Related:** #109 · #112 (merged) · #114 · #72 · PR #3 (session-store untrack) - -## Why “cached data was removed” - -| Layer | What it holds | Persistence policy | -|-------|---------------|--------------------| -| **Auth session** | Cookies, Bearer tokens, PoW answers/signatures, browser profile | **Ephemeral only** — `$RUNNER_TEMP` / `$HOME` override inside the job; discarded at job end. Never Actions cache, never commit, never artifact. | -| **Work context** | Task key, last-reviewed SHA, provider `session_id` *hash* (not the token), last action type, short status flags | **Durable (non-secret)** — Actions cache + optional PR/issue comment marker. | - -PR **#112 / #114** deliberately dropped DeepSeek session/cookie caching after CodeRabbit + Devin required that no session material land in the tree or cross-run secrets surface. That fixed a security class problem; it also removed the accidental ability to “pick up the same chat.” - -#118 is the product follow-up: **continue the *work*, not the *credentials*.** - -## What you can continue / regenerate / edit - -Using `.github/actions/agent-context-store`: - -1. **Load** prior context for a stable key (`pr-123-review`, `issue-118-triage`, …). -2. **Decide** whether to skip (same SHA already reviewed), re-diff only changed files, or regenerate. -3. **Save** updated non-secret fields after the run. -4. Optional **comment marker** `` so humans/agents see a pointer on the issue/PR. - -Forbidden keys are stripped on both load and save: `token`, `cookie(s)`, `authorization`, `api_key`, `pow_*`, `password`, `secret`, and any key containing those substrings. - -## Usage sketch - -```yaml -- name: Load work context - id: ctx - uses: ./.github/actions/agent-context-store - with: - mode: load - context_key: pr-${{ github.event.pull_request.number }}-review - -- name: Agent step - # use fromJSON(steps.ctx.outputs.context_json).last_sha etc. - -- name: Save work context - if: always() - uses: ./.github/actions/agent-context-store - with: - mode: save - context_key: pr-${{ github.event.pull_request.number }}-review - context_json: | - {"last_sha":"${{ github.event.pull_request.head.sha }}","last_action":"review","provider_session_hash":"${{ steps.hash.outputs.value }}"} - github_token: ${{ secrets.GITHUB_TOKEN }} - target_number: ${{ github.event.pull_request.number }} - repository: ${{ github.repository }} -``` - -## Auth session path (DeepSeek web-wrapper and similar) - -Keep the #114 pattern: - -```bash -export HOME="${RUNNER_TEMP}/deepseek-webwrapper-home" -mkdir -p "$HOME" -# … create_session / chat … -rm -rf "${RUNNER_TEMP}/deepseek-webwrapper-home" # always() -``` - -If a provider returns a `session_id`, store only a **hash** (or the last 8 chars for logs) inside work context — never the full id + token pair. - -## Gemini / peer review continuity - -Existing Gemini review already uses soft-budget skip comments and router peers. Prefer embedding **SHA bookmarks** in work context rather than re-introducing cookie caches. - -## Operator checklist - -- [ ] Secrets stay in GitHub Secrets / local `~/.deepcli` (ignored). -- [ ] No `session.json` / `cookies_*.json` in the index (see `.gitignore` + #3). -- [ ] New workflows call `agent-context-store` for any multi-run agent task. -- [ ] Regenerating a response = new invoke with prior `last_sha` / task flags loaded, not replaying auth cookies. - -## Trace - -| Artifact | Role | -|----------|------| -| #118 | Continuity requirement | -| #109 | DeepSeek CI integration source | -| #112 | Executable workflow + ephemeral session policy (merged) | -| #114 | Peer routing + web-wrapper opt-in (open) | -| #72 | Quota + session continuation foundations | -| #3 | Untrack historical session stores | diff --git a/docs/proposals/active/rate-limit-rotation/ITEMS.md b/docs/proposals/active/rate-limit-rotation/ITEMS.md index 3affb6d14..97a6be93e 100644 --- a/docs/proposals/active/rate-limit-rotation/ITEMS.md +++ b/docs/proposals/active/rate-limit-rotation/ITEMS.md @@ -6,9 +6,9 @@ | RL-02 | model-router composite action | P0 | grok | done | .github/actions/model-router | | RL-03 | Wire triage → Flash-Lite | P0 | grok | done | gemini-triage.yml | | RL-04 | Wire review → Flash w/ Lite fallback | P0 | grok | done | gemini-review.yml | -| RL-05 | OpenRouter fallback path | P0 | grok | executing | #123 landed ELO+poll router; http-llm-invoke fallback still tracked | +| RL-05 | OpenRouter fallback path | P0 | grok | foundation | skip=true when Gemini exhausted; real HTTP invoke still TODO | | RL-06 | Tighten job-gate daily-limit (900→100) | P1 | grok | todo | pair with #81 | -| RL-07 | Prompt compression for agent workflows | P1 | | todo | issue #90; PR #126 | +| RL-07 | Prompt compression for agent workflows | P1 | | todo | issue #90 | | RL-08 | OmniRoute hub integration surface | P2 | | todo | issue #91 | | RL-09 | Per-model Linear quota dashboard | P2 | | todo | | | RL-10 | Merge/rebase #81 quota-gate onto master | P0 | | todo | PR #81 | @@ -18,5 +18,4 @@ | RL-14 | Devin Apply Suggestions automation | P1 | | partial | enable Auto-Fix in Devin Settings; no public click-API | | RL-15 | Honest OpenRouter (no false route) | P0 | grok | done | model-router skip=true always when Gemini exhausted | | RL-16 | Shared global counter (not per-branch cache) | P2 | | todo | gist/issue optimistic concurrency | -| RL-17 | Model availability polling & ELO (3L0) routing | P0 | jules | done | #123 scripts/model_router.py, model-success-matrix.yaml | -| RL-18 | DeepSeek CI peer path (no Class 3/4 cache) | P0 | jules | blocked | #134 security hold — ephemeral session only | +| RL-17 | Model availability polling & ELO (3L0) routing | P0 | jules | done | scripts/model_router.py, docs/schemas/model-success-matrix.yaml | diff --git a/multi-ai-cli/ci_mode.py b/multi-ai-cli/ci_mode.py new file mode 100644 index 000000000..d0b3f4169 --- /dev/null +++ b/multi-ai-cli/ci_mode.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +CI mode for multi-ai-cli – runs the agent non-interactively using DeepSeek, +reading GitHub event payload, and outputting JSON securely. +""" +import os +import sys +import json +import argparse +import subprocess +from pathlib import Path + +# Use relative module paths inside multi-ai-cli +sys.path.insert(0, str(Path(__file__).parent)) + +from core.session_manager import SessionManager +import backends.deepseek as ds_mod +from backends.deepseek import DeepSeekBackend + +def run_ci(event, workspace, operator_token): + """ + Non-interactive agent loop using DeepSeek web-wrapper. + """ + # Dynamically resolve WASM_SOLVER path in GHA + ds_mod.WASM_SOLVER = Path(workspace) / "deepcli" / "pow_solver.js" + + # Set up GitHub CLI env with token + gh_env = os.environ.copy() + if operator_token: + gh_env['GH_TOKEN'] = operator_token + + pr_number = event.get('pull_request', {}).get('number') + repo = event.get('repository', {}).get('full_name') or os.environ.get('GITHUB_REPOSITORY') + action = event.get('action') + + decisions = [] + + if action in ['opened', 'synchronize', 'reopened'] and pr_number and repo: + # Get PR diff + diff_cmd = ['gh', 'pr', 'diff', str(pr_number), '--repo', repo] + try: + diff = subprocess.check_output(diff_cmd, env=gh_env, text=True) + except Exception as e: + diff = f"Could not retrieve diff: {e}" + + # Initialize the DeepSeek backend and perform code review + try: + # DeepSeek token can be loaded from env or config.yaml + if not os.environ.get("DEEPSEEK_TOKEN"): + os.environ["DEEPSEEK_TOKEN"] = operator_token or "" + + mgr = SessionManager() + backend = DeepSeekBackend(mgr) + + prompt = f"You are a code reviewer. Analyze the diff and suggest improvements:\n\n{diff[:8000]}" + analysis = backend.send_message(prompt, []) + + # Ensure we only post PR comment on successful analysis (skip error/mock strings) + if analysis and not analysis.startswith("Error:") and not analysis.startswith("[No content returned]"): + # Tracing signature metadata suffix + signature = f"\n\n---\n*Bot Review powered by @deepseek-cli{{provider: deepseek, model: deepseek-reasoner}}*" + comment_body = analysis[:1900] + signature + + # Comment on the PR + comment_cmd = ['gh', 'pr', 'comment', str(pr_number), '--body', comment_body, '--repo', repo] + try: + subprocess.run(comment_cmd, env=gh_env, check=False) + except Exception as e: + print(f"Failed to post PR comment: {e}") + + decisions.append({ + 'type': 'pr_review', + 'pr': pr_number, + 'summary': comment_body[:200], + }) + else: + print(f"Warning: DeepSeek analysis returned empty or invalid review: {analysis}", file=sys.stderr) + except Exception as e: + print(f"Error during DeepSeek analysis: {e}", file=sys.stderr) + + return { + 'actions': decisions, + 'event': event.get('action'), + 'pr': pr_number, + 'provider_used': 'deepseek', + } + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--workspace', required=True, help='GitHub workspace path') + parser.add_argument('--cache-dir', default='/tmp/deepseek-cache', help='Directory for session cache') + args = parser.parse_args() + + # Enforce secure directory creation (0o700) + os.makedirs(args.cache_dir, exist_ok=True) + try: + os.chmod(args.cache_dir, 0o700) + except Exception: + pass + + # Read event from GITHUB_EVENT_PATH + event = {} + event_path = os.environ.get('GITHUB_EVENT_PATH') + if event_path and os.path.exists(event_path): + try: + with open(event_path, 'r') as f: + event = json.load(f) + except Exception as e: + print(f"Warning: Failed to load event payload from GITHUB_EVENT_PATH: {e}") + + # Run DeepSeek CI + result = run_ci( + event=event, + workspace=args.workspace, + operator_token=os.environ.get('OPERATOR_TOKEN') + ) + + # Secure output serialization with 0o600 permissions + output_path = Path('deepseek_output.json') + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + mode = 0o600 + try: + fd = os.open(output_path, flags, mode) + with open(fd, 'w') as f: + json.dump(result, f, indent=2) + except Exception: + with open(output_path, 'w') as f: + json.dump(result, f, indent=2) + + try: + os.chmod(output_path, 0o600) + except Exception: + pass + + print(f"✅ CI run completed. Decisions: {result.get('actions', [])}") + +if __name__ == '__main__': + main() diff --git a/scripts/model_router.py b/scripts/model_router.py old mode 100644 new mode 100755 index bd464450a..4bd008905 --- a/scripts/model_router.py +++ b/scripts/model_router.py @@ -4,12 +4,7 @@ Implements Model Availability Polling with smart temporal caching, dynamic ELO-based (3L0) ranking, and soft-budget validation. -Soft budgets raised 2026-08-10 (OPERATOR) to exceed prior free-tier headroom. -See .github/connectors/llm-peers.yaml for authorized catalog. - -LEGACY_MODELS is the conservative catalog-unavailable allow-list: only models -proven in production. Newly listed free models must appear in a live/stale -OpenRouter poll before they are selected. +This script replaces rigid bash logic with robust Python standard library code. """ import os @@ -17,39 +12,49 @@ import re import json import urllib.request +import ssl import time COUNTER_DIR = os.environ.get("COUNTER_DIR", "/tmp/model-router") -# Proven-only fallback when OpenRouter catalog cannot be fetched. -# Do NOT add unverified free models here — they belong in role_peers and the live poll. +# Previously known-good models to fall back on when no live catalog is available LEGACY_MODELS = { "meta-llama/llama-3.3-70b-instruct:free", "google/gemma-3-12b-it:free", "qwen/qwen3-coder:free", "deepseek/deepseek-r1:free", - "auto/best-free", + "auto/best-free" } def parse_yaml(filepath): + """ + Minimal robust YAML parser for standard key-value and indentation structures. + Does not require external dependencies like PyYAML. + """ if not os.path.exists(filepath): return {} + result = {} path = [] + with open(filepath, 'r', encoding='utf-8') as f: for line in f: stripped = line.strip() if not stripped or stripped.startswith('#'): continue + indent = len(line) - len(line.lstrip()) + while path and path[-1][0] >= indent: path.pop() + if not path: parent = result parent_key = None else: parent = path[-1][1] parent_key = path[-1][2] + if stripped.startswith('- '): item_str = stripped[2:].strip() if ':' in item_str and not (item_str.startswith('"') or item_str.startswith("'")): @@ -69,6 +74,7 @@ def parse_yaml(filepath): else: item_val = int(item_val) except ValueError: pass + if isinstance(parent, dict) and parent_key is not None: if parent_key not in parent or not isinstance(parent[parent_key], list): parent[parent_key] = [] @@ -77,14 +83,17 @@ def parse_yaml(filepath): sub_key = list(item_val.keys())[0] path.append((indent, item_val, sub_key)) continue + m = re.match(r'^("[^"]+"|\'[^\']+\'|[^:]+):\s*(.*)$', stripped) if not m: continue + key = m.group(1).strip().strip('"\'') val = m.group(2).strip() if '#' in val: val = val.split('#')[0].strip() val = val.strip('"\'') + if not val: new_dict = {} if isinstance(parent, dict): @@ -106,18 +115,25 @@ def parse_yaml(filepath): parsed_val = int(val) except ValueError: parsed_val = val + if isinstance(parent, dict): parent[key] = parsed_val path.append((indent, parent, key)) + return result + def fetch_openrouter_free_models(): - """Poll OpenRouter models endpoint; return free model ids or None on failure.""" + """ + Polls the OpenRouter models endpoint to find available free models. + """ url = "https://openrouter.ai/api/v1/models" try: req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + # Use standard SSL verification by default for secure transfers with urllib.request.urlopen(req, timeout=5) as response: data = json.loads(response.read().decode('utf-8')) + free_models = [] for m in data.get('data', []): m_id = m.get('id', '') @@ -126,6 +142,7 @@ def fetch_openrouter_free_models(): if m_id.endswith(':free'): is_free = True else: + # Robust check converting pricing prompt/completion to float try: p_prompt = float(pricing.get('prompt', 0.0)) p_compl = float(pricing.get('completion', 0.0)) @@ -133,8 +150,11 @@ def fetch_openrouter_free_models(): is_free = True except (ValueError, TypeError): pass + if is_free and m_id: free_models.append(m_id) + + # Prevent caching an empty list if there's a temporary API anomaly if not free_models: return None return free_models @@ -142,11 +162,17 @@ def fetch_openrouter_free_models(): sys.stderr.write(f"Warning: Failed to poll OpenRouter models ({e}). Using hardcoded fallback list.\n") return None + def fetch_openrouter_free_models_cached(): - """1h cache; on miss return stale cache if present, else live poll.""" + """ + Retrieves the list of OpenRouter free models using a 1-hour temporal file cache. + Optimizes polling intervals to avoid redundant network overhead. + """ cache_file = os.path.join(COUNTER_DIR, "openrouter_models_cache.json") cached_models = None cached_time = 0 + + # Try loading existing cache if os.path.exists(cache_file): try: with open(cache_file, "r") as f: @@ -155,24 +181,38 @@ def fetch_openrouter_free_models_cached(): cached_time = cache_data.get("timestamp", 0) except Exception: pass + + # If cache is valid (less than 1 hour old), return it immediately if cached_models is not None and (time.time() - cached_time < 3600): sys.stderr.write("Using cached OpenRouter free models list.\n") return cached_models + + # Cache is missing or expired, fetch fresh list from API models = fetch_openrouter_free_models() if models is not None: try: os.makedirs(COUNTER_DIR, exist_ok=True) with open(cache_file, "w") as f: - json.dump({"timestamp": time.time(), "models": models}, f) + json.dump({ + "timestamp": time.time(), + "models": models + }, f) except Exception: pass return models + + # Fetch failed! Fall back to stale cached models if available to avoid unverified routes if cached_models is not None: sys.stderr.write("Warning: OpenRouter fresh poll failed. Falling back to stale cached models list.\n") return cached_models + return None + def get_usage(provider, model): + """ + Returns the daily counter for a model. + """ key = f"{provider}/{model}" if provider != "gemini" else model filename = os.path.join(COUNTER_DIR, key.replace('/', '_') + ".txt") if os.path.exists(filename): @@ -183,7 +223,11 @@ def get_usage(provider, model): return 0 return 0 + def increment_usage(provider, model): + """ + Increments and saves the daily counter for a model. + """ key = f"{provider}/{model}" if provider != "gemini" else model os.makedirs(COUNTER_DIR, exist_ok=True) filename = os.path.join(COUNTER_DIR, key.replace('/', '_') + ".txt") @@ -192,38 +236,43 @@ def increment_usage(provider, model): f.write(str(current + 1)) return current + 1 + def main(): + # Load input arguments or environment variables role = os.environ.get("ROLE", "triage") has_omni = os.environ.get("HAS_OMNI", "false").lower() == "true" has_openrouter = os.environ.get("HAS_OPENROUTER", "false").lower() == "true" has_gemini = os.environ.get("HAS_GEMINI", "true").lower() == "true" + # Load configuration schemas matrix_path = "docs/schemas/model-success-matrix.yaml" success_matrix = parse_yaml(matrix_path) + # 1. Poll OpenRouter models for availability (only if has_openrouter is enabled) polled_free_models = None if has_openrouter: polled_free_models = fetch_openrouter_free_models_cached() - # ELEVATED soft limits (OPERATOR 2026-08-10) — exceed prior capacity. - # Soft gates only; downstream 429 must still re-route / fall through. + # 2. Define candidates per role and retrieve limit mapping + # Defaults in case schemas are missing or incomplete limits = { - "omni/auto/best-free": {"triage": 400, "review": 250, "invoke": 400}, - "openrouter/meta-llama/llama-3.3-70b-instruct:free": {"triage": 80, "review": 80, "invoke": 80}, - "openrouter/google/gemma-3-12b-it:free": {"triage": 80, "review": 80, "invoke": 80}, - "openrouter/qwen/qwen3-coder:free": {"triage": 60, "review": 60, "invoke": 60}, - "openrouter/deepseek/deepseek-r1:free": {"triage": 40, "review": 40, "invoke": 40}, - "openrouter/google/gemma-4-31b-it:free": {"triage": 80, "review": 80, "invoke": 80}, - "openrouter/google/gemma-4-26b-a4b-it:free": {"triage": 80, "review": 80, "invoke": 80}, - "openrouter/cohere/north-mini-code:free": {"triage": 60, "review": 60, "invoke": 60}, + "omni/auto/best-free": {"triage": 200, "review": 120, "invoke": 200}, + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {"triage": 40, "review": 40, "invoke": 40}, + "openrouter/google/gemma-3-12b-it:free": {"triage": 40, "review": 40, "invoke": 40}, + "openrouter/qwen/qwen3-coder:free": {"triage": 30, "review": 30, "invoke": 30}, + "openrouter/deepseek/deepseek-r1:free": {"triage": 20, "review": 20, "invoke": 20}, + "openrouter/google/gemma-4-31b-it:free": {"triage": 40, "review": 40, "invoke": 40}, + "openrouter/google/gemma-4-26b-a4b-it:free": {"triage": 40, "review": 40, "invoke": 40}, + "openrouter/cohere/north-mini-code:free": {"triage": 30, "review": 30, "invoke": 30}, "gemini-3.1-flash-lite": {"triage": 450, "review": 450, "invoke": 450}, "gemini-3.5-flash-lite": {"triage": 450, "review": 450, "invoke": 450}, - "gemini-2.5-flash-lite": {"triage": 20, "review": 20, "invoke": 20}, - "gemini-3.5-flash": {"triage": 20, "review": 20, "invoke": 20}, - "gemini-2.5-flash": {"triage": 20, "review": 20, "invoke": 20}, - "gemini-3-flash": {"triage": 20, "review": 20, "invoke": 20}, + "gemini-2.5-flash-lite": {"triage": 15, "review": 15, "invoke": 15}, + "gemini-3.5-flash": {"triage": 15, "review": 15, "invoke": 15}, + "gemini-2.5-flash": {"triage": 15, "review": 15, "invoke": 15}, + "gemini-3-flash": {"triage": 15, "review": 15, "invoke": 15}, } + # Preferred lists per role role_peers = { "triage": [ ("omni", "auto/best-free"), @@ -255,51 +304,73 @@ def main(): "invoke": ["gemini-3.1-flash-lite", "gemini-3.5-flash-lite"], } + # 3. Score and rank peer candidates peer_candidates = [] for provider, model in role_peers.get(role, []): if provider == "omni" and not has_omni: continue if provider == "openrouter" and not has_openrouter: continue + + # Security Assertion: guard against non-free model ids on OpenRouter paths if provider == "openrouter" and not model.endswith(":free"): - sys.stderr.write(f"Warning: non-free model ID '{model}' skipped.\n") + sys.stderr.write(f"Warning: Model router security check failed — non-free model ID '{model}' skipped.\n") continue + + # Cross-reference OpenRouter polled availability if provider == "openrouter": if polled_free_models is not None: if model not in polled_free_models: - sys.stderr.write(f"Warning: OpenRouter model {model} not in polled catalog. Skipping.\n") + sys.stderr.write(f"Warning: OpenRouter model {model} is not currently available/free in polled catalog. Skipping.\n") continue else: + # No catalog (fresh or cached) available at all! Skip unverified new models conservatively. if model not in LEGACY_MODELS: - sys.stderr.write(f"Warning: No catalog. Skipping unverified '{model}'.\n") + sys.stderr.write(f"Warning: No OpenRouter catalog available. Skipping unverified new model '{model}' conservatively.\n") continue + + # Calculate ELO score from success matrix (ELO ≈ 3L0) model_entry = success_matrix.get("models", {}).get(model, {}) elo = model_entry.get("elo", 1000) suitability = model_entry.get("role_suitability", {}).get(role, 1.0) score = elo * suitability - peer_candidates.append({"provider": provider, "model": model, "score": score}) + peer_candidates.append({ + "provider": provider, + "model": model, + "score": score + }) + + # Sort peers by ELO score descending peer_candidates.sort(key=lambda x: x["score"], reverse=True) + # 4. Attempt routing to highest ranked peer with capacity for candidate in peer_candidates: prov = candidate["provider"] mod = candidate["model"] limit_dict = limits.get(f"{prov}/{mod}", {}) limit = limit_dict.get(role, limit_dict.get("default", 40)) + used = get_usage(prov, mod) if used < limit: new_used = increment_usage(prov, mod) - reason = f"role={role} peer={prov} model={mod} used={new_used}/{limit} (ranked score={candidate['score']})" print(f"::set-output name=provider::{prov}") print(f"::set-output name=model::{mod}") print(f"::set-output name=skip::false") - print(f"::set-output name=reason::{reason}") + print(f"::set-output name=reason::role={role} peer={prov} model={mod} used={new_used}/{limit} (ranked score={candidate['score']})") + + # GITHUB_OUTPUT file support (newer GitHub Actions convention) if "GITHUB_OUTPUT" in os.environ: with open(os.environ["GITHUB_OUTPUT"], "a") as go: - go.write(f"provider={prov}\nmodel={mod}\nskip=false\nreason={reason}\n") + go.write(f"provider={prov}\n") + go.write(f"model={mod}\n") + go.write(f"skip=false\n") + go.write(f"reason=role={role} peer={prov} model={mod} used={new_used}/{limit} (ranked score={candidate['score']})\n") return + sys.stderr.write(f"Peer soft budget exhausted: {prov}/{mod} ({used}/{limit})\n") + # 5. Fallback to Gemini residuals if peers are exhausted if has_gemini: gemini_candidates = [] for model in role_residuals.get(role, []): @@ -307,47 +378,70 @@ def main(): elo = model_entry.get("elo", 1000) suitability = model_entry.get("role_suitability", {}).get(role, 1.0) score = elo * suitability - gemini_candidates.append({"provider": "gemini", "model": model, "score": score}) + + gemini_candidates.append({ + "provider": "gemini", + "model": model, + "score": score + }) + + # Sort Gemini candidates by ELO score descending gemini_candidates.sort(key=lambda x: x["score"], reverse=True) + for candidate in gemini_candidates: mod = candidate["model"] limit_dict = limits.get(mod, {}) limit = limit_dict.get(role, limit_dict.get("default", 15)) + used = get_usage("gemini", mod) if used < limit: new_used = increment_usage("gemini", mod) - reason = f"role={role} gemini={mod} used={new_used}/{limit} (ranked score={candidate['score']}) residual" print(f"::set-output name=provider::gemini") print(f"::set-output name=model::{mod}") print(f"::set-output name=skip::false") - print(f"::set-output name=reason::{reason}") + print(f"::set-output name=reason::role={role} gemini={mod} used={new_used}/{limit} (ranked score={candidate['score']}) residual") + if "GITHUB_OUTPUT" in os.environ: with open(os.environ["GITHUB_OUTPUT"], "a") as go: - go.write(f"provider=gemini\nmodel={mod}\nskip=false\nreason={reason}\n") + go.write(f"provider=gemini\n") + go.write(f"model={mod}\n") + go.write(f"skip=false\n") + go.write(f"reason=role={role} gemini={mod} used={new_used}/{limit} (ranked score={candidate['score']}) residual\n") return + sys.stderr.write(f"Gemini residual soft budget exhausted: {mod} ({used}/{limit})\n") - reason = f"all free paths exhausted (omni/openrouter peers + gemini residual) role={role}" - print("::set-output name=provider::none") - print("::set-output name=model::") - print("::set-output name=skip::true") - print(f"::set-output name=reason::{reason}") + # 6. All free options exhausted + print(f"::set-output name=provider::none") + print(f"::set-output name=model::") + print(f"::set-output name=skip::true") + print(f"::set-output name=reason::all free paths exhausted (omni/openrouter peers + gemini residual) role={role}") + if "GITHUB_OUTPUT" in os.environ: with open(os.environ["GITHUB_OUTPUT"], "a") as go: - go.write(f"provider=none\nmodel=\nskip=true\nreason={reason}\n") + go.write(f"provider=none\n") + go.write(f"model=\n") + go.write(f"skip=true\n") + go.write(f"reason=all free paths exhausted (omni/openrouter peers + gemini residual) role={role}\n") + if __name__ == "__main__": try: main() except Exception as e: - sys.stderr.write(f"Error: Model Router crashed: {e}\n") + sys.stderr.write(f"Error: Model Router crashed during execution: {e}\n") + # Graceful degradation fallback outputs print("::set-output name=provider::none") print("::set-output name=model::") print("::set-output name=skip::true") print(f"::set-output name=reason::Model Router crashed: {e}") + if "GITHUB_OUTPUT" in os.environ: try: with open(os.environ["GITHUB_OUTPUT"], "a") as go: - go.write(f"provider=none\nmodel=\nskip=true\nreason=Model Router crashed: {e}\n") + go.write("provider=none\n") + go.write("model=\n") + go.write("skip=true\n") + go.write(f"reason=Model Router crashed: {e}\n") except Exception: pass diff --git a/tests/test_multi_ai_ci.py b/tests/test_multi_ai_ci.py new file mode 100644 index 000000000..814dbc0f7 --- /dev/null +++ b/tests/test_multi_ai_ci.py @@ -0,0 +1,108 @@ +import os +import json +import tempfile +import shutil +import pytest +from pathlib import Path + +# Add multi-ai-cli directory to python path for imports +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "multi-ai-cli")) + +from ci_mode import run_ci, main + +def test_run_ci_permissions(): + # Create secure temporary directory + temp_dir = tempfile.mkdtemp() + try: + # We will mock the DeepSeekBackend send_message to return a mock string + # so that it executes completely offline without network requirements + import backends.deepseek as ds_mod + original_send = ds_mod.DeepSeekBackend.send_message + ds_mod.DeepSeekBackend.send_message = lambda self, msg, context: "Mocked Code Review: LGTM!" + + # Mock os.environ to contain GITHUB_EVENT_PATH + event_path = Path(temp_dir) / 'event.json' + event_data = { + 'action': 'synchronize', + 'pull_request': {'number': 137}, + 'repository': {'full_name': 'test/repo'} + } + with open(event_path, 'w') as f: + json.dump(event_data, f) + + os.environ['GITHUB_EVENT_PATH'] = str(event_path) + os.environ['OPERATOR_TOKEN'] = 'test_token' + + # Call run_ci + result = run_ci( + event=event_data, + workspace=temp_dir, + operator_token='test_token' + ) + + assert result is not None + assert result['provider_used'] == 'deepseek' + assert result['event'] == 'synchronize' + assert len(result['actions']) == 1 + assert "Mocked Code Review" in result['actions'][0]['summary'] + + # Restore original function + ds_mod.DeepSeekBackend.send_message = original_send + + finally: + shutil.rmtree(temp_dir) + +def test_ci_output_permissions(monkeypatch): + # Mock ci_mode main execution + temp_dir = tempfile.mkdtemp() + try: + cache_dir = Path(temp_dir) / 'cache' + event_path = Path(temp_dir) / 'event.json' + event_data = { + 'action': 'opened', + 'pull_request': {'number': 137}, + 'repository': {'full_name': 'test/repo'} + } + with open(event_path, 'w') as f: + json.dump(event_data, f) + + monkeypatch.setenv('GITHUB_EVENT_PATH', str(event_path)) + monkeypatch.setenv('OPERATOR_TOKEN', 'test_token') + + import backends.deepseek as ds_mod + monkeypatch.setattr( + ds_mod.DeepSeekBackend, + "send_message", + lambda self, msg, ctx: "Mocked output!" + ) + + # Execute main with argparse arguments + monkeypatch.setattr(sys, "argv", [ + "ci_mode.py", + "--workspace", temp_dir, + "--cache-dir", str(cache_dir) + ]) + + # Run main() + # Since deepseek_output.json is written in the current working directory, + # we change directory temporarily, or mock its output path if needed. + # Let's mock write location to prevent side-effects on current repo + original_cwd = os.getcwd() + os.chdir(temp_dir) + try: + main() + + # Verify cache_dir exists with 0o700 + assert cache_dir.exists() + assert (os.stat(cache_dir).st_mode & 0o777) == 0o700 + + # Verify deepseek_output.json exists with 0o600 + output_file = Path('deepseek_output.json') + assert output_file.exists() + assert (os.stat(output_file).st_mode & 0o777) == 0o600 + finally: + os.chdir(original_cwd) + + finally: + shutil.rmtree(temp_dir)