Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This guide shows how to install `github-agent-bridge` for a new OpenClaw deploym
Before installing the bridge, have these ready:

- Python 3.11 or newer.
- OpenClaw CLI installed and able to run `openclaw agent`.
- OpenClaw CLI installed with `openclaw infer model run --local --prompt --json` support for the intent classifier.
- GitHub CLI (`gh`) installed and authenticated as the GitHub user/bot that should react to comments.
- An email inbox that receives GitHub notification emails.
- IMAP access to that inbox, usually an app password.
Expand Down
14 changes: 10 additions & 4 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,16 @@ count. Otherwise four busy workers can fill a four-slot OpenClaw queue and
starve other gateway work.

The enqueue-time intent classifier is deliberately different: the bridge calls
`openclaw agent --local` for an isolated one-shot classification. This keeps the
reader independent from gateway queue saturation, event-loop stalls, and
gateway SQLite lock contention. Classifier calls are sequential in the reader,
so they do not add another pool of concurrent bridge jobs.
`openclaw infer model run --local --prompt --json` for a one-shot raw model
classification. This avoids both the long-lived Gateway session path and the
`agent exec` coding-agent tool surface for the permission decision. The OpenClaw
CLI on classifier hosts must expose `openclaw infer model run` with `--local`,
`--prompt`, `--json`, `--model`, and `--thinking`; the bridge checks that
contract before invoking the classifier. The raw model inference path does not
load repository tools, bundled MCP servers, or session transcript. The bridge
limits classifier subprocess concurrency, retries once, and treats webhook
comment/review classifier failure as `review_only`, so a classifier outage or
unsupported CLI cannot elevate webhook work to `work_allowed`.

For the standard four-worker deployment, use eight OpenClaw slots:

Expand Down
23 changes: 15 additions & 8 deletions docs/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -767,12 +767,19 @@ Agents must also apply the comment value rule before posting: comment only when

## Intent classifier

`intentClassifier` controls an optional enqueue-time LLM classifier for trusted GitHub comments and reviews. It is disabled by default; when enabled, the bridge calls OpenClaw with the packaged `prompt_rules/intent_classifier.md` prompt or `promptOverrides.rules.intent_classifier`, expects JSON output, and uses the result only when confidence is high enough. Low-confidence, invalid, timed-out, or failed classifier calls fall back to the deterministic parser result.

Classifier calls use `openclaw agent --local`, isolating enqueue-time routing
from gateway concurrency, event-loop stalls, and gateway SQLite locks. Normal
executor, feedback-learning, and interactive gateway calls still need suitable
OpenClaw concurrency headroom; see
`intentClassifier` controls an optional enqueue-time LLM classifier for trusted GitHub comments and reviews. It is disabled by default; when enabled, the bridge calls OpenClaw with the packaged `prompt_rules/intent_classifier.md` prompt or `promptOverrides.rules.intent_classifier`, expects JSON output, and uses the result only when confidence is high enough. Webhook comment/review parser results are still guarded even when the classifier is disabled: only a high-confidence applied classifier result, or the explicit structured `pr_authored_by_bot` feedback path, may keep/elevate webhook comment work to `work_allowed`. Low-confidence, invalid, timed-out, failed, or disabled webhook comment/review classifier paths fall back to `review_only`; email ingestion keeps the deterministic parser result for backward compatibility.

Classifier calls use `openclaw infer model run --local --prompt --json`, not
`openclaw agent exec`. This uses OpenClaw's raw model inference path, which does
not start a coding-agent turn, load repository tools, load bundled MCP servers,
or include prior session transcript. The bridge verifies the `openclaw infer
model run --help` contract before classification, retries once, limits
classifier subprocess concurrency, and treats failure as `review_only` for
webhook comments/reviews. Deployments that enable the classifier must run an
OpenClaw CLI that exposes `infer model run`, `--local`, `--prompt`, `--json`,
`--model`, and `--thinking`.
Normal executor, feedback-learning, and interactive gateway calls still need
suitable OpenClaw concurrency headroom; see
[`operations.md`](operations.md#openclaw-concurrency-headroom).

The classifier returns structured semantics: whether the event is addressed to the configured agent, the requested action, the work intent, write permission, and the scope of any requested state change. Results not addressed to the configured agent are normalized to `archive_notification` + `review_only`. Results that request `work_allowed` without `write_permission=state_change_allowed` are normalized back to `review_only`.
Expand Down Expand Up @@ -802,8 +809,8 @@ Example:
| `minConfidence` | number | `0.75` | Minimum classifier confidence required before replacing the deterministic parser action/work-intent result. |
| `onlyWhenParserDefaulted` | boolean | `true` | Run the classifier for conservative comment/review classifications and for bot-mentioned comments even when the parser saw implementation-looking language. Set to `false` to let the classifier arbitrate all eligible trusted GitHub comments and reviews. |
| `openclawBin` | string | `openclaw` | OpenClaw executable used for classifier subprocess calls. |
| `sessionId` | string | `github-agent-bridge-intent` | Base session id prefix for classifier calls. The bridge derives an isolated per-event session id from this value plus routed agent and GitHub notification/context data. |
| `timeout` | integer | `60` | Maximum seconds to wait for one classifier call before falling back to parser behavior. |
| `sessionId` | string | `github-agent-bridge-intent` | Backward-compatible setting retained for older deployments. Current classifier execution uses raw `openclaw infer model run` and does not rely on a long-lived OpenClaw session id. |
| `timeout` | integer | `60` | Maximum seconds to wait for one classifier call before falling back conservatively for webhook comments/reviews. |

The classifier runs before policy decision mapping, but it does not grant trust or bypass authorization. Source trust, `enabledRepos`, `actions`, `trustedRepos`, `trustedOrgs`, routes, roles, and the final `Policy.decision` checks still apply after classification.

Expand Down
162 changes: 130 additions & 32 deletions src/github_agent_bridge/intent_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import os
import re
import subprocess
import threading
from dataclasses import dataclass
from functools import lru_cache
from typing import Any

from .feedback import _extract_json_object, _openclaw_text_from_json, compact, load_prompt_rule, session_id_for_event
from .feedback import _extract_json_object, compact, load_prompt_rule, session_id_for_event
from .models import GitHubContext, Notification
from .parser import github_event_flags
from .policy import DEFAULT_BOT_LOGINS, IntentClassifier, Policy
Expand All @@ -25,6 +27,9 @@
ALLOWED_COMPLEXITIES = {"mechanical", "substantive"}
INTENT_CLASSIFIER_PROMPT = load_prompt_rule("intent_classifier.md")
COMMENT_TARGET_KINDS = {"issue_comment", "review_comment", "commit_comment", "review"}
INTENT_CLASSIFIER_CONCURRENCY = 2
INTENT_CLASSIFIER_ATTEMPTS = 2
_INTENT_CLASSIFIER_SEMAPHORE = threading.BoundedSemaphore(INTENT_CLASSIFIER_CONCURRENCY)
GITHUB_EMAIL_FOOTER_RE = re.compile(
r"(?:\n\s*--\s*)?\n\s*Reply to this email directly or view it on GitHub:.*$",
re.IGNORECASE | re.DOTALL,
Expand Down Expand Up @@ -75,6 +80,89 @@ def to_metadata(self) -> dict[str, object]:
return metadata


def _text_from_model_run_json(raw: str) -> str:
data = json.loads(raw)
if isinstance(data, str):
return data
if not isinstance(data, dict):
return raw
candidates = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Cal parsejar l’envelope real de model.run. La sortida JSON de openclaw infer model run --local --json (verificat a OpenClaw 2026.7.1-2) és de la forma {"ok":true,...,"outputs":[{"text":"{...classificació...}","mediaUrl":null}]}. Aquest helper no consulta outputs; només mira camps singulars i l’antic result.payloads. Per tant retorna l’envelope complet, _extract_json_object() n’extreu l’objecte exterior i normalize_result() produeix applied=False amb confiança 0. Això fa que fins i tot una inferència correcta caigui sempre al guardrail review_only i el classificador continuï desactivat de facto. El test nou també emmascara el problema perquè mockeja result.payloads, no el contracte real de raw inference. Cal llegir outputs[*].text i afegir una regressió amb l’envelope real.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolt a f1a88cd: _text_from_model_run_json() ara llegeix outputs[*].text de l’envelope real de openclaw infer model run --local --json abans dels formats legacy, i he canviat el test del camí principal perquè mockegi aquest contracte real.

Validació local:

  • PYTHONPATH=src /home/openclaw/.openclaw/workspace/github/github-agent-bridge/.venv/bin/python -m pytest tests/test_intent_classifier.py -q -> 15 passed, 1 skipped
  • PYTHONPATH=src /home/openclaw/.openclaw/workspace/github/github-agent-bridge/.venv/bin/python -m pytest tests/test_intent_classifier.py tests/test_queue.py tests/test_webhook.py tests/test_persistence_architecture.py -q -> 128 passed, 1 skipped
  • git diff --check -> net

PR body actualitzat. Els checks del push nou encara estan en curs.

data.get("text"),
data.get("output_text"),
data.get("output"),
data.get("content"),
data.get("message"),
data.get("response"),
]
outputs = data.get("outputs")
if isinstance(outputs, list):
for output in outputs:
if isinstance(output, dict):
candidates.append(output.get("text"))
candidates.append(output.get("content"))
result = data.get("result")
if isinstance(result, dict):
candidates.extend(
[
result.get("text"),
result.get("output_text"),
result.get("output"),
result.get("content"),
result.get("message"),
result.get("response"),
]
)
outputs = result.get("outputs")
if isinstance(outputs, list):
for output in outputs:
if isinstance(output, dict):
candidates.append(output.get("text"))
candidates.append(output.get("content"))
payloads = result.get("payloads")
if isinstance(payloads, list):
for payload in payloads:
if isinstance(payload, dict):
candidates.append(payload.get("text"))
candidates.append(payload.get("content"))
for candidate in candidates:
if isinstance(candidate, str) and candidate.strip():
return candidate
return raw


@lru_cache(maxsize=8)
def assert_openclaw_model_run_supported(openclaw_bin: str) -> None:
try:
proc = subprocess.run(
[openclaw_bin, "infer", "model", "run", "--help"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired) as exc:
detail = compact(str(exc), 300)
raise RuntimeError(
f"OpenClaw CLI does not support required tool-less model run contract: {detail}"
)

output = proc.stdout + proc.stderr
required = (
"Usage: openclaw infer model run",
"--local",
"--prompt",
"--json",
"--model",
"--thinking",
)
if proc.returncode != 0 or not all(token in output for token in required):
detail = compact(output.strip() or f"openclaw exited {proc.returncode}", 300)
raise RuntimeError(
f"OpenClaw CLI does not support required tool-less model run contract: {detail}"
)


def should_classify_with_llm(
n: Notification,
ctx: GitHubContext,
Expand Down Expand Up @@ -215,47 +303,57 @@ def classify_notification_with_llm(
agent: str | None = None,
prompt_template: str | None = None,
) -> IntentClassification:
assert_openclaw_model_run_supported(cfg.openclaw_bin)
prompt = build_intent_prompt(n, ctx, parser_result, prompt_template, policy=policy, agent=agent)
cmd = [
cfg.openclaw_bin,
"agent",
"infer",
"model",
"run",
"--local",
"--json",
"--session-id",
intent_session_id(cfg.session_id, n, ctx, agent),
"--timeout",
str(cfg.timeout),
"--prompt",
prompt,
"--thinking",
cfg.thinking,
"--message",
build_intent_prompt(n, ctx, parser_result, prompt_template, policy=policy, agent=agent),
]
if agent:
cmd.extend(["--agent", agent])
if cfg.model:
cmd.extend(["--model", cfg.model])
env = os.environ.copy()
openclaw_dir = os.path.dirname(cfg.openclaw_bin)
if openclaw_dir:
env["PATH"] = openclaw_dir + os.pathsep + env.get("PATH", "")
try:
proc = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=cfg.timeout + 30,
env=env,
)
except subprocess.TimeoutExpired as exc:
# TimeoutExpired.__str__ contains the complete command and prompt, so
# metadata truncation hides the useful part of the failure.
detail = exc.stderr or exc.stdout or ""
if isinstance(detail, bytes):
detail = detail.decode(errors="replace")
suffix = f": {compact(detail, 300)}" if detail.strip() else ""
raise RuntimeError(f"intent classifier timed out after {cfg.timeout + 30}s{suffix}") from exc
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or f"openclaw exited {proc.returncode}")
text = _openclaw_text_from_json(proc.stdout)
return normalize_result(_extract_json_object(text), cfg.min_confidence)
errors: list[str] = []
with _INTENT_CLASSIFIER_SEMAPHORE:
for attempt in range(1, INTENT_CLASSIFIER_ATTEMPTS + 1):
try:
proc = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=cfg.timeout + 30,
env=env,
)
except subprocess.TimeoutExpired as exc:
# TimeoutExpired.__str__ contains the complete command and prompt, so
# metadata truncation hides the useful part of the failure.
detail = exc.stderr or exc.stdout or ""
if isinstance(detail, bytes):
detail = detail.decode(errors="replace")
suffix = f": {compact(detail, 300)}" if detail.strip() else ""
errors.append(f"attempt {attempt}: timed out after {cfg.timeout + 30}s{suffix}")
continue
if proc.returncode != 0:
errors.append(
f"attempt {attempt}: "
+ compact(proc.stderr.strip() or proc.stdout.strip() or f"openclaw exited {proc.returncode}", 300)
)
continue
try:
text = _text_from_model_run_json(proc.stdout)
return normalize_result(_extract_json_object(text), cfg.min_confidence)
except Exception as exc:
errors.append(f"attempt {attempt}: {compact(str(exc), 300)}")
raise RuntimeError("intent classifier failed after retries: " + " | ".join(errors))
17 changes: 16 additions & 1 deletion src/github_agent_bridge/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from .policy import Policy
from . import feedback
from .actors import trigger_actor_details_for_enqueue, trigger_actor_details_from_notification
from .intent_classifier import ParserResult, classify_notification_with_llm, should_classify_with_llm
from .intent_classifier import COMMENT_TARGET_KINDS, ParserResult, classify_notification_with_llm, should_classify_with_llm
from .sql.migrations import SCHEMA, initialize_database, validate_migrations


Expand Down Expand Up @@ -145,6 +145,12 @@ def ingest(
if bot_authored_changes_requested:
intent = "work_allowed"
parser_result = ParserResult(action, intent)
classifier_required = (
source == "webhook"
and ctx.target_kind in COMMENT_TARGET_KINDS
and policy.trusted_source(n, ctx)
)
classifier_applied = False
if should_classify_with_llm(n, ctx, parser_result, policy):
metadata["intent_classifier"] = {
"parser": {"action": action, "work_intent": intent},
Expand Down Expand Up @@ -172,6 +178,7 @@ def ingest(
if llm_result.applied:
action = llm_result.action
intent = llm_result.work_intent
classifier_applied = True
except Exception as exc:
metadata["intent_classifier"] = {
**metadata["intent_classifier"],
Expand All @@ -185,6 +192,14 @@ def ingest(
metadata["intent_guardrail"] = (
"bot_authored_pr_changes_requested_work_allowed"
)
elif (
classifier_required
and feedback_actionability != "pr_authored_by_bot"
and not classifier_applied
and intent == "work_allowed"
):
intent = "review_only"
metadata["intent_guardrail"] = "webhook_classifier_required_read_only"
if action == "submit_review":
intent = "review_only"
metadata["intent_guardrail"] = "submit_review_read_only"
Expand Down
Loading
Loading