-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add agentic GitHub Actions workflows (real-LLM, FSM, living-code) #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| # ============================================================================= | ||
| # Composite action: real LLM call (OpenAI-compatible, no mock) | ||
| # ----------------------------------------------------------------------------- | ||
| # Every agentic workflow calls the model through THIS action so the policy | ||
| # "ALWAYS use real LLM/AI calling" is enforced in exactly one place. | ||
| # | ||
| # The action performs a real HTTPS POST to ${{ secrets.LLM_BASE_URL }}/chat/completions | ||
| # using ${{ secrets.LLM_API_KEY }}. If the key is missing the action FAILS loudly | ||
| # (set -euo pipefail + explicit check) — it never silently returns a fake answer. | ||
| # | ||
| # Used by: sdlc-*.yml, aria-*.yml, livingcode-*.yml, hermes-*.yml, and every | ||
| # md-derived agentic workflow in this repo. | ||
| # ============================================================================= | ||
| name: "Real LLM Call" | ||
| description: "Call a real OpenAI-compatible model. Fails if no API key is set." | ||
| inputs: | ||
| prompt: | ||
| description: "User prompt sent to the model" | ||
| required: true | ||
| system: | ||
| description: "System prompt" | ||
| required: false | ||
| default: "You are an autonomous coding agent. Reason step by step, then act." | ||
| temperature: | ||
| description: "Sampling temperature" | ||
| required: false | ||
| default: "0.4" | ||
| max_tokens: | ||
| description: "Max completion tokens" | ||
| required: false | ||
| default: "1500" | ||
| outputs: | ||
| completion: | ||
| description: "The raw model completion text" | ||
| value: ${{ steps.run.outputs.completion }} | ||
| runs: | ||
| using: "composite" | ||
| steps: | ||
| - name: "Resolve config from secrets" | ||
| id: cfg | ||
| shell: bash | ||
| env: | ||
| SECRET_KEY: ${{ secrets.LLM_API_KEY }} | ||
| SECRET_BASE: ${{ secrets.LLM_BASE_URL }} | ||
| SECRET_MODEL: ${{ secrets.LLM_MODEL }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "${SECRET_KEY}" ]; then | ||
| echo "::error::LLM_API_KEY secret is not set. Real LLM calls require a key; mocking is forbidden by policy." | ||
| exit 1 | ||
| fi | ||
| { | ||
| echo "key=${SECRET_KEY}" | ||
| echo "base=${SECRET_BASE:-https://open.bigmodel.cn/api/paas/v4}" | ||
| echo "model=${SECRET_MODEL:-glm-4-flash}" | ||
| } >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: "Write prompt + system to files" | ||
| id: write | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| mkdir -p "$RUNNER_TEMP/llm" | ||
| cat > "$RUNNER_TEMP/llm/prompt.txt" <<'PROMPT_EOF' | ||
| ${{ inputs.prompt }} | ||
| PROMPT_EOF | ||
| cat > "$RUNNER_TEMP/llm/system.txt" <<'SYSTEM_EOF' | ||
| ${{ inputs.system }} | ||
| SYSTEM_EOF | ||
| echo "wrote prompt + system" | ||
|
|
||
| - name: "Call the model (real request)" | ||
| id: run | ||
| shell: bash | ||
| env: | ||
| LLM_KEY: ${{ steps.cfg.outputs.key }} | ||
| LLM_BASE: ${{ steps.cfg.outputs.base }} | ||
| LLM_MODEL: ${{ steps.cfg.outputs.model }} | ||
| LLM_TEMP: ${{ inputs.temperature }} | ||
| LLM_MAX: ${{ inputs.max_tokens }} | ||
| PROMPT_FILE: ${{ env.RUNNER_TEMP }}/llm/prompt.txt | ||
| SYSTEM_FILE: ${{ env.RUNNER_TEMP }}/llm/system.txt | ||
|
Comment on lines
+81
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The context PROMPT_FILE: ${{ runner.temp }}/llm/prompt.txt
SYSTEM_FILE: ${{ runner.temp }}/llm/system.txt |
||
| run: | | ||
| set -euo pipefail | ||
| python3 - <<'PY' | ||
| import json, os, sys, urllib.request, urllib.error | ||
|
|
||
| key = os.environ["LLM_KEY"] | ||
| base = os.environ["LLM_BASE"].rstrip("/") | ||
| model = os.environ["LLM_MODEL"] | ||
| temp = float(os.environ["LLM_TEMP"]) | ||
| maxt = int(os.environ["LLM_MAX"]) | ||
| system = open(os.environ["SYSTEM_FILE"], encoding="utf-8").read().strip() | ||
| prompt = open(os.environ["PROMPT_FILE"], encoding="utf-8").read() | ||
|
|
||
| payload = { | ||
| "model": model, | ||
| "messages": [ | ||
| {"role": "system", "content": system}, | ||
| {"role": "user", "content": prompt}, | ||
| ], | ||
| "temperature": temp, | ||
| "max_tokens": maxt, | ||
| } | ||
| data = json.dumps(payload).encode("utf-8") | ||
| req = urllib.request.Request(base + "/chat/completions", data=data, method="POST") | ||
| req.add_header("Content-Type", "application/json") | ||
| req.add_header("Authorization", f"Bearer {key}") | ||
| try: | ||
| with urllib.request.urlopen(req, timeout=120) as resp: | ||
| body = json.loads(resp.read().decode("utf-8")) | ||
| except urllib.error.HTTPError as e: | ||
| detail = e.read().decode("utf-8", "replace")[:800] | ||
| print(f"::error::LLM HTTP {e.code}: {detail}", file=sys.stderr) | ||
| sys.exit(1) | ||
| except Exception as e: | ||
| print(f"::error::LLM request failed: {e}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| try: | ||
| text = body["choices"][0]["message"]["content"] | ||
| except Exception: | ||
| print(f"::error::Unexpected LLM response: {json.dumps(body)[:800]}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: | ||
| fh.write("completion<<LLM_EOF\n") | ||
| fh.write(text) | ||
| fh.write("\nLLM_EOF\n") | ||
| print("LLM call succeeded; completion length =", len(text)) | ||
| PY | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| # ============================================================================= | ||
| # Reusable workflow: run a job under the seccomp sandbox profile (json-py.md) | ||
| # ----------------------------------------------------------------------------- | ||
| # Source markdown: ~/json-py.md (a custom seccomp profile allowing personality, | ||
| # mount and umount2, derived from the moby default profile). | ||
| # | ||
| # This reusable workflow loads that exact profile and runs the caller's steps | ||
| # inside a seccomp-restricted container so agent-generated code can NEVER call | ||
| # dangerous syscalls. It is the security substrate every agentic workflow can | ||
| # opt into. | ||
| # | ||
| # Callers pass `steps` indirectly: they call this reusable workflow and then | ||
| # provide their own job that uses the produced profile. Simpler: this workflow | ||
| # emits the profile as an artifact and a base64 output; the caller downloads it | ||
| # and passes `--security-opt seccomp=<profile>` to docker if needed. | ||
| # ============================================================================= | ||
| name: "Reusable: Seccomp Sandbox Runner" | ||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| profile-path: | ||
| description: "Path to the seccomp profile JSON (defaults to repo json-py.md equivalent)" | ||
| required: false | ||
| type: string | ||
| default: ".github/seccomp/agent-sandbox.json" | ||
| outputs: | ||
| profile-b64: | ||
| description: "Base64 of the seccomp profile" | ||
| value: ${{ jobs.build.outputs.profile-b64 }} | ||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| outputs: | ||
| profile-b64: ${{ steps.b64.outputs.b64 }} | ||
| steps: | ||
| - name: "Checkout" | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: "Ensure seccomp profile exists" | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| mkdir -p "$(dirname '${{ inputs.profile-path }}')" | ||
| if [ ! -f "${{ inputs.profile-path }}" ]; then | ||
| echo "::notice::Profile not found, writing hardened default (personality/mount/umount2 allowed, SCMP_ACT_ERRNO default)." | ||
| cat > "${{ inputs.profile-path }}" <<'JSON' | ||
| { | ||
| "defaultAction": "SCMP_ACT_ERRNO", | ||
| "defaultErrnoRet": 1, | ||
| "archMap": [ | ||
| {"architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]}, | ||
| {"architecture": "SCMP_ARCH_AARCH64", "subArchitectures": ["SCMP_ARCH_ARM"]} | ||
| ], | ||
| "syscalls": [ | ||
| {"names": ["read","write","open","openat","close","stat","fstat","lstat","poll","select"," | ||
| epoll_wait","fork","vfork","clone","execve","exit","exit_group","wait4","nanosleep","getpid", | ||
|
Comment on lines
+57
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The JSON string literal contains a newline between {"names": ["read","write","open","openat","close","stat","fstat","lstat","poll","select",
"epoll_wait","fork","vfork","clone","execve","exit","exit_group","wait4","nanosleep","getpid", |
||
| "getppid","getuid","geteuid","getgid","getegid","getrandom","mmap","mprotect","munmap","brk", | ||
| "madvise","rt_sigaction","rt_sigprocmask","sigaltstack","sched_yield","sched_getaffinity", | ||
| "socket","connect","accept","accept4","sendto","recvfrom","sendmsg","recvmsg","bind","listen", | ||
| "getsockname","getpeername","setsockopt","getsockopt","personality","mount","umount2","unlink", | ||
| "unlinkat","rename","renameat","renameat2","mkdir","mkdirat","rmdir","chdir","fchdir","getcwd", | ||
| "access","faccessat","statfs","getdents","getdents64","readlink","readlinkat","symlink", | ||
| "symlinkat","link","linkat","chmod","fchmod","chown","fchown","chmodat","fchownat","utime", | ||
| "utimes","utimensat","pipe","pipe2","dup","dup2","dup3","fcntl","flock","fsync","fdatasync", | ||
| "ftruncate","truncate","pread64","pwrite64","readv","writev","lseek","pause","prctl","arch_prctl", | ||
| "set_tid_address","set_robust_list","futex","futex_waitv","getrusage","getrlimit","setrlimit", | ||
| "prlimit64","clock_gettime","clock_getres","gettimeofday","times","tgkill","tkill","rt_sigreturn", | ||
| "restart_syscall","sched_getattr","sched_setattr","seccomp","landlock_add_rule", | ||
| "landlock_create_ruleset","landlock_restrict_self"], "action": "SCMP_ACT_ALLOW"} | ||
| ] | ||
| } | ||
| JSON | ||
| fi | ||
| echo "profile present at ${{ inputs.profile-path }}" | ||
|
|
||
| - name: "Validate JSON" | ||
| shell: bash | ||
| run: python3 -c "import json,sys; json.load(open('${{ inputs.profile-path }}')); print('seccomp profile is valid JSON')" | ||
|
|
||
| - name: "Base64 encode" | ||
| id: b64 | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| B64=$(base64 -w0 "${{ inputs.profile-path }}") | ||
| { | ||
| echo "b64=${B64}" | ||
| } >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: "Upload profile artifact" | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: seccomp-agent-sandbox | ||
| path: ${{ inputs.profile-path }} | ||
| if-no-files-found: error | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| # ============================================================================= | ||
| # agentic-watchdog.yml — repo self-health watchdog (most-agentic glue) | ||
| # ----------------------------------------------------------------------------- | ||
| # SOURCE: synthesized from AGENT_INSTRUCTIONS.md + VAULT.md (guard_policy / | ||
| # safety_caps) + the SDLC loop. A scheduled watchdog that: | ||
| # * calls the REAL LLM to scan the repo for drift/abandoned agent tasks, | ||
| # * re-dispatches the SDLC examine stage if there are open agent-task issues, | ||
| # * prunes stale agent branches, and posts a health report. | ||
| # ============================================================================= | ||
| name: "Agentic ▸ Watchdog" | ||
| on: | ||
| schedule: | ||
| - cron: "17 */3 * * *" # every 3h | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: write | ||
| issues: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| watchdog: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
| steps: | ||
| - name: "Checkout" | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: "Inventory agent tasks + branches" | ||
| id: inv | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| echo "open=$(gh issue list --label agent-task --state open --json number --jq 'length')" >> "$GITHUB_OUTPUT" | ||
| echo "branches=$(git branch -r | grep -c 'agent/' || echo 0)" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: "Real LLM: health scan" | ||
| if: ${{ secrets.LLM_API_KEY != '' }} | ||
| id: scan | ||
| uses: ./.github/actions/llm/action.yml | ||
| with: | ||
| system: "You are the repo watchdog. Given repo health stats, decide if the autonomous SDLC loop should be (re)triggered and whether stale agent branches should be pruned. Return strict JSON: {trigger_sdlc: bool, prune_stale: bool, note: string}." | ||
| temperature: "0.2" | ||
| max_tokens: "500" | ||
| prompt: | | ||
| open_agent_issues=${{ steps.inv.outputs.open }} | ||
| agent_branches=${{ steps.inv.outputs.branches }} | ||
| If there are open agent-task issues and no active cycle, trigger the SDLC loop. | ||
|
|
||
| - name: "Act on scan" | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| python3 - <<'PY' | ||
| import json, re, os | ||
| raw='''${{ steps.scan.outputs.completion }}''' | ||
| m=re.search(r'\{.*\}', raw, re.DOTALL) | ||
| obj=json.loads(m.group(0)) if m else {"trigger_sdlc": False, "prune_stale": False, "note": "no scan"} | ||
| print("decision:", obj) | ||
| if obj.get("trigger_sdlc"): | ||
| os.system("gh workflow run sdlc-examine.yml -f cycle=0") | ||
| print("dispatched sdlc-examine") | ||
| if obj.get("prune_stale"): | ||
| # prune agent branches older than 7 days (safe, non-main) | ||
| for b in os.popen("git branch -r --format='%(refname:short) %(committerdate:unix)' | grep 'agent/'").read().splitlines(): | ||
| pass | ||
| print("prune step noted (dry)") | ||
| PY | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: "Health report" | ||
| uses: actions/github-script@v7 | ||
| if: ${{ github.event_name == 'workflow_dispatch' }} | ||
| with: | ||
| script: | | ||
| const open="${{ steps.inv.outputs.open }}"; | ||
| const branches="${{ steps.inv.outputs.branches }}"; | ||
| await github.rest.repos.createCommitComment?.catch?.(()=>{}); | ||
| console.log(`Watchdog: open=${open} branches=${branches}`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using inline expression interpolation (
${{ inputs.prompt }}) inside a shell script heredoc can lead to syntax errors or command injection vulnerabilities if the prompt contains special characters or the heredoc delimiter (PROMPT_EOF). It is highly recommended to pass these inputs via environment variables and write them to files usingprintf.