feat(runner): unify plugin execution across agents and event processors

This commit is contained in:
RockChinQ
2026-09-10 18:04:38 +08:00
parent 8903a40c41
commit f24a7c9bb2
223 changed files with 4091 additions and 3068 deletions
@@ -99,7 +99,7 @@ LangBot 是异步且集成度高的系统,有些问题不会直接表现为页
```text
Action list_plugins call timed out
Action list_agent_runners call timed out
Action list_runners call timed out
Action invoke_llm_stream call timed out
```
+3 -3
View File
@@ -61,7 +61,7 @@ bin/lbs fixture check
```
`env doctor` 会检查 URL、路径、代理变量等。代理变量是可选项;只有大小写代理变量互相冲突时才会报错。失败不一定代表仓库坏了,通常说明本地 LangBot 没启动、代理不一致或浏览器 profile 不存在。
`fixture check` 会检查仓库内测试 fixture 是否存在,例如 MCP stdio server、RAG 文档、多模态图片、qa-plugin-smoke 包和 QA AgentRunner 包。它也会校验 `.lbpkg` 是 zip 包,并检查 QA AgentRunner fixture 的入口文件未漂移。
`fixture check` 会检查仓库内测试 fixture 是否存在,例如 MCP stdio server、RAG 文档、多模态图片、qa-plugin-smoke 包和 QA Runner 包。它也会校验 `.lbpkg` 是 zip 包,并检查 QA Runner fixture 的入口文件未漂移。
4. 查看已有测试 case
@@ -344,8 +344,8 @@ npx playwright install chromium
脚本会尝试通过 `LANGBOT_PIPELINE_NAME` 从 Pipelines 页面进入目标 pipeline。两者都没有时,
该自动化会返回 `blocked`,不会伪造通过。
Runner 专用 case 不应复用通用 pipeline 变量。Local Agent、Codex AgentRunner 和
Claude Code AgentRunner 这类 case 会通过 `automation_pipeline_url_env` /
Runner 专用 case 不应复用通用 pipeline 变量。Local Agent、Codex Runner 和
Claude Code Runner 这类 case 会通过 `automation_pipeline_url_env` /
`automation_pipeline_name_env` 映射到 case-specific env,例如
`LANGBOT_LOCAL_AGENT_PIPELINE_URL`。这些 case 如果缺少专用变量,会返回 `blocked`
不会退回到 `LANGBOT_PIPELINE_URL`,避免跑错 pipeline 后产生假阳性。
+195 -164
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Audit one persisted AgentRunner run without exposing authorization secrets."""
"""Audit one persisted Runner run without exposing authorization secrets."""
from __future__ import annotations
@@ -25,29 +25,29 @@ from agent_run_ledger_policy import (
def database_url(repo: pathlib.Path) -> str:
config = yaml.safe_load((repo / "data/config.yaml").read_text(encoding="utf-8")) or {}
database = config.get("database", {})
kind = database.get("use", "sqlite")
if kind == "sqlite":
path = pathlib.Path(database.get("sqlite", {}).get("path", "data/langbot.db"))
config = yaml.safe_load((repo / 'data/config.yaml').read_text(encoding='utf-8')) or {}
database = config.get('database', {})
kind = database.get('use', 'sqlite')
if kind == 'sqlite':
path = pathlib.Path(database.get('sqlite', {}).get('path', 'data/langbot.db'))
if not path.is_absolute():
path = repo / path
return f"sqlite+aiosqlite:///{path}"
if kind in {"postgres", "postgresql"}:
values = database.get("postgresql", {})
user = urllib.parse.quote_plus(str(values.get("user", "postgres")))
password = urllib.parse.quote_plus(str(values.get("password", "postgres")))
host = values.get("host", "127.0.0.1")
port = values.get("port", 5432)
name = values.get("database", "postgres")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
raise RuntimeError(f"Unsupported database backend: {kind}")
return f'sqlite+aiosqlite:///{path}'
if kind in {'postgres', 'postgresql'}:
values = database.get('postgresql', {})
user = urllib.parse.quote_plus(str(values.get('user', 'postgres')))
password = urllib.parse.quote_plus(str(values.get('password', 'postgres')))
host = values.get('host', '127.0.0.1')
port = values.get('port', 5432)
name = values.get('database', 'postgres')
return f'postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}'
raise RuntimeError(f'Unsupported database backend: {kind}')
def parse_created_after(value: str | None) -> datetime.datetime | None:
if not value:
return None
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
parsed = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
@@ -55,19 +55,19 @@ def parse_created_after(value: str | None) -> datetime.datetime | None:
def event_matches_tool_call(data_json: str | None, tool_name: str, parameters: dict | None) -> bool:
try:
data = json.loads(data_json or "{}")
data = json.loads(data_json or '{}')
except (TypeError, ValueError):
return False
if not isinstance(data, dict) or data.get("tool_name") != tool_name:
if not isinstance(data, dict) or data.get('tool_name') != tool_name:
return False
return parameters is None or data.get("parameters") == parameters
return parameters is None or data.get('parameters') == parameters
def collect_result_texts(value: object) -> list[str]:
texts: list[str] = []
if isinstance(value, dict):
for key, item in value.items():
if key == "text" and isinstance(item, str):
if key == 'text' and isinstance(item, str):
texts.append(item)
else:
texts.extend(collect_result_texts(item))
@@ -85,7 +85,7 @@ async def audit(
expected_tool_name: str | None = None,
expected_parameters: dict | None = None,
expected_result_text: str | None = None,
tool_authorization_mode: str = "strict",
tool_authorization_mode: str = 'strict',
) -> dict:
engine = create_async_engine(database_url(repo))
failures: list[dict] = []
@@ -93,82 +93,106 @@ async def audit(
try:
async with engine.connect() as connection:
if run_id:
run_row = (await connection.execute(
sqlalchemy.text("SELECT * FROM agent_run WHERE run_id = :run_id"),
{"run_id": run_id},
)).mappings().first()
run_row = (
(
await connection.execute(
sqlalchemy.text('SELECT * FROM agent_run WHERE run_id = :run_id'),
{'run_id': run_id},
)
)
.mappings()
.first()
)
elif expected_tool_name:
query = "SELECT * FROM agent_run"
query = 'SELECT * FROM agent_run'
params = {}
if created_after is not None:
query += " WHERE created_at >= :created_after"
params["created_after"] = created_after
query += " ORDER BY id DESC LIMIT 100"
query += ' WHERE created_at >= :created_after'
params['created_after'] = created_after
query += ' ORDER BY id DESC LIMIT 100'
candidates = (await connection.execute(sqlalchemy.text(query), params)).mappings().all()
run_row = None
for candidate in candidates:
started_rows = (await connection.execute(
sqlalchemy.text(
"SELECT data_json FROM agent_run_event "
"WHERE run_id = :run_id AND type = 'tool.call.started' ORDER BY sequence"
),
{"run_id": str(candidate["run_id"])},
)).mappings().all()
started_rows = (
(
await connection.execute(
sqlalchemy.text(
'SELECT data_json FROM agent_run_event '
"WHERE run_id = :run_id AND type = 'tool.call.started' ORDER BY sequence"
),
{'run_id': str(candidate['run_id'])},
)
)
.mappings()
.all()
)
if any(
event_matches_tool_call(row.get("data_json"), expected_tool_name, expected_parameters)
event_matches_tool_call(row.get('data_json'), expected_tool_name, expected_parameters)
for row in started_rows
):
run_row = candidate
break
else:
run_row = (await connection.execute(
sqlalchemy.text("SELECT * FROM agent_run ORDER BY id DESC LIMIT 1")
)).mappings().first()
run_row = (
(await connection.execute(sqlalchemy.text('SELECT * FROM agent_run ORDER BY id DESC LIMIT 1')))
.mappings()
.first()
)
if run_row is None:
status = "fail" if expected_tool_name else "env_issue"
status = 'fail' if expected_tool_name else 'env_issue'
return {
"status": status,
"reason": "No AgentRunner run contains the expected tool call." if expected_tool_name else "No matching AgentRunner run exists.",
"failures": [{"kind": "expected_tool_call_missing"}] if expected_tool_name else [],
"warnings": [],
'status': status,
'reason': 'No Runner run contains the expected tool call.'
if expected_tool_name
else 'No matching Runner run exists.',
'failures': [{'kind': 'expected_tool_call_missing'}] if expected_tool_name else [],
'warnings': [],
}
selected_run_id = str(run_row["run_id"])
event_rows = (await connection.execute(
sqlalchemy.text("SELECT sequence, type, data_json, metadata_json FROM agent_run_event WHERE run_id = :run_id ORDER BY sequence"),
{"run_id": selected_run_id},
)).mappings().all()
selected_run_id = str(run_row['run_id'])
event_rows = (
(
await connection.execute(
sqlalchemy.text(
'SELECT sequence, type, data_json, metadata_json FROM agent_run_event WHERE run_id = :run_id ORDER BY sequence'
),
{'run_id': selected_run_id},
)
)
.mappings()
.all()
)
finally:
await engine.dispose()
authorization = load_ledger_json(
run_row.get("authorization_json"),
field="agent_run.authorization_json",
run_row.get('authorization_json'),
field='agent_run.authorization_json',
failures=failures,
)
tools = authorization.get("resources", {}).get("tools", []) if isinstance(authorization, dict) else []
tools = authorization.get('resources', {}).get('tools', []) if isinstance(authorization, dict) else []
allowed_tools: dict[str, dict] = {}
incomplete_tool_metadata: list[dict] = []
for tool in tools if isinstance(tools, list) else []:
if not isinstance(tool, dict):
incomplete_tool_metadata.append({"tool_name": "", "missing": ["tool object"]})
incomplete_tool_metadata.append({'tool_name': '', 'missing': ['tool object']})
continue
name = str(tool.get("tool_name", ""))
name = str(tool.get('tool_name', ''))
missing = []
if not name:
missing.append("tool_name")
if not str(tool.get("description", "")).strip():
missing.append("description")
if not isinstance(tool.get("parameters"), dict):
missing.append("parameters")
if not (tool.get("source") or tool.get("tool_type") or tool.get("source_id")):
missing.append("owner")
missing.append('tool_name')
if not str(tool.get('description', '')).strip():
missing.append('description')
if not isinstance(tool.get('parameters'), dict):
missing.append('parameters')
if not (tool.get('source') or tool.get('tool_type') or tool.get('source_id')):
missing.append('owner')
if missing:
incomplete_tool_metadata.append({"tool_name": name, "missing": missing})
incomplete_tool_metadata.append({'tool_name': name, 'missing': missing})
if name:
allowed_tools[name] = tool
if incomplete_tool_metadata:
failures.append({"kind": "incomplete_tool_metadata", "tools": incomplete_tool_metadata})
failures.append({'kind': 'incomplete_tool_metadata', 'tools': incomplete_tool_metadata})
starts: dict[str, list[dict]] = {}
completions: dict[str, list[dict]] = {}
@@ -178,7 +202,7 @@ async def audit(
invalid_tool_argument_errors: list[dict] = []
successful_tool_completion_sequences: list[int] = []
forbidden_pattern = re.compile(
r"invalid json(?! arguments)|unauthori[sz]ed|permission denied|forbidden|timed?\s*out|timeout",
r'invalid json(?! arguments)|unauthori[sz]ed|permission denied|forbidden|timed?\s*out|timeout',
re.I,
)
@@ -189,55 +213,53 @@ async def audit(
return collected
for key, item in value.items():
normalized = str(key).lower()
if normalized in {"error", "code", "status", "reason", "error_message"} and item is not None and item != "":
if normalized in {'error', 'code', 'status', 'reason', 'error_message'} and item is not None and item != '':
collected.append(str(item))
if isinstance(item, dict):
collected.extend(error_surface(item))
return collected
for row in event_rows:
event_type = str(row["type"])
event_type = str(row['type'])
event_types.append(event_type)
before = len(failures)
data = load_ledger_json(
row.get("data_json"),
field=f"agent_run_event[{row['sequence']}].data_json",
row.get('data_json'),
field=f'agent_run_event[{row["sequence"]}].data_json',
failures=failures,
)
invalid_event_json += int(len(failures) > before)
if not isinstance(data, dict):
failures.append({"kind": "invalid_event_payload", "sequence": row["sequence"], "type": event_type})
failures.append({'kind': 'invalid_event_payload', 'sequence': row['sequence'], 'type': event_type})
continue
if event_type in {"tool.call.started", "tool.call.completed"}:
call_id = str(data.get("tool_call_id", ""))
item = {"sequence": row["sequence"], "tool_name": str(data.get("tool_name", "")), "data": data}
if event_type in {'tool.call.started', 'tool.call.completed'}:
call_id = str(data.get('tool_call_id', ''))
item = {'sequence': row['sequence'], 'tool_name': str(data.get('tool_name', '')), 'data': data}
if not call_id:
failures.append({"kind": "missing_tool_call_id", "sequence": row["sequence"], "type": event_type})
elif event_type == "tool.call.started":
failures.append({'kind': 'missing_tool_call_id', 'sequence': row['sequence'], 'type': event_type})
elif event_type == 'tool.call.started':
starts.setdefault(call_id, []).append(item)
else:
completions.setdefault(call_id, []).append(item)
if not data.get("error") and data.get("result") is not None:
successful_tool_completion_sequences.append(row["sequence"])
diagnostic_text = "\n".join(error_surface(data))
if event_type == "run.failed":
diagnostic_text += "\n" + json.dumps(data, ensure_ascii=True)
if not data.get('error') and data.get('result') is not None:
successful_tool_completion_sequences.append(row['sequence'])
diagnostic_text = '\n'.join(error_surface(data))
if event_type == 'run.failed':
diagnostic_text += '\n' + json.dumps(data, ensure_ascii=True)
match = forbidden_pattern.search(diagnostic_text)
if match:
suspicious_errors.append({"sequence": row["sequence"], "type": event_type, "signal": match.group(0)})
elif event_type == "tool.call.completed":
suspicious_errors.append({'sequence': row['sequence'], 'type': event_type, 'signal': match.group(0)})
elif event_type == 'tool.call.completed':
signal = invalid_tool_argument_error_signal(diagnostic_text)
if signal:
invalid_tool_argument_errors.append(
{"sequence": row["sequence"], "type": event_type, "signal": signal}
)
invalid_tool_argument_errors.append({'sequence': row['sequence'], 'type': event_type, 'signal': signal})
if run_row["status"] != "completed":
failures.append({"kind": "run_status", "actual": run_row["status"], "expected": "completed"})
if "run.completed" not in event_types:
failures.append({"kind": "missing_run_completed_event"})
if "run.failed" in event_types:
failures.append({"kind": "run_failed_event"})
if run_row['status'] != 'completed':
failures.append({'kind': 'run_status', 'actual': run_row['status'], 'expected': 'completed'})
if 'run.completed' not in event_types:
failures.append({'kind': 'missing_run_completed_event'})
if 'run.failed' in event_types:
failures.append({'kind': 'run_failed_event'})
all_call_ids = sorted(set(starts) | set(completions))
unauthorized_calls = []
@@ -245,14 +267,21 @@ async def audit(
started = starts.get(call_id, [])
completed = completions.get(call_id, [])
if len(started) != 1 or len(completed) != 1:
failures.append({"kind": "tool_call_pairing", "tool_call_id": call_id, "started": len(started), "completed": len(completed)})
failures.append(
{
'kind': 'tool_call_pairing',
'tool_call_id': call_id,
'started': len(started),
'completed': len(completed),
}
)
continue
if started[0]["tool_name"] != completed[0]["tool_name"]:
failures.append({"kind": "tool_name_mismatch", "tool_call_id": call_id})
if started[0]["sequence"] >= completed[0]["sequence"]:
failures.append({"kind": "tool_call_order", "tool_call_id": call_id})
if started[0]["tool_name"] not in allowed_tools:
unauthorized_calls.append({"tool_call_id": call_id, "tool_name": started[0]["tool_name"]})
if started[0]['tool_name'] != completed[0]['tool_name']:
failures.append({'kind': 'tool_name_mismatch', 'tool_call_id': call_id})
if started[0]['sequence'] >= completed[0]['sequence']:
failures.append({'kind': 'tool_call_order', 'tool_call_id': call_id})
if started[0]['tool_name'] not in allowed_tools:
unauthorized_calls.append({'tool_call_id': call_id, 'tool_name': started[0]['tool_name']})
authorization_failures, authorization_warnings = classify_tool_authorization(
unauthorized_calls,
authorization_mode=tool_authorization_mode,
@@ -263,20 +292,18 @@ async def audit(
invalid_tool_argument_errors,
successful_tool_completion_sequences=successful_tool_completion_sequences,
run_completed=(
run_row["status"] == "completed"
and "run.completed" in event_types
and "run.failed" not in event_types
run_row['status'] == 'completed' and 'run.completed' in event_types and 'run.failed' not in event_types
),
)
if unrecovered_argument_errors:
suspicious_errors.extend(unrecovered_argument_errors)
warnings.extend(recovered_argument_warnings)
if suspicious_errors:
failures.append({"kind": "forbidden_error_signals", "events": suspicious_errors})
failures.append({'kind': 'forbidden_error_signals', 'events': suspicious_errors})
if not event_rows:
failures.append({"kind": "missing_run_events"})
failures.append({'kind': 'missing_run_events'})
if not tools:
warnings.append({"kind": "no_authorized_tools", "reason": "The run authorization snapshot exposes no tools."})
warnings.append({'kind': 'no_authorized_tools', 'reason': 'The run authorization snapshot exposes no tools.'})
expected_call_summary = None
if expected_tool_name:
@@ -284,97 +311,101 @@ async def audit(
item
for items in starts.values()
for item in items
if item["tool_name"] == expected_tool_name
and (expected_parameters is None or item["data"].get("parameters") == expected_parameters)
if item['tool_name'] == expected_tool_name
and (expected_parameters is None or item['data'].get('parameters') == expected_parameters)
]
if len(matching_starts) != 1:
failures.append({"kind": "expected_tool_call_count", "actual": len(matching_starts), "expected": 1})
failures.append({'kind': 'expected_tool_call_count', 'actual': len(matching_starts), 'expected': 1})
matching_completions = []
for started in matching_starts:
call_id = str(started["data"].get("tool_call_id", ""))
call_id = str(started['data'].get('tool_call_id', ''))
matching_completions.extend(completions.get(call_id, []))
result_text_match = expected_result_text is None or any(
expected_result_text in collect_result_texts(completed["data"].get("result"))
expected_result_text in collect_result_texts(completed['data'].get('result'))
for completed in matching_completions
)
if expected_result_text is not None and not result_text_match:
failures.append({"kind": "expected_tool_result_text_missing"})
failures.append({'kind': 'expected_tool_result_text_missing'})
expected_call_summary = {
"tool_name": expected_tool_name,
"parameters_match_required": expected_parameters is not None,
"matched_started_count": len(matching_starts),
"matched_completed_count": len(matching_completions),
"result_text_match_required": expected_result_text is not None,
"result_text_match": result_text_match,
'tool_name': expected_tool_name,
'parameters_match_required': expected_parameters is not None,
'matched_started_count': len(matching_starts),
'matched_completed_count': len(matching_completions),
'result_text_match_required': expected_result_text is not None,
'result_text_match': result_text_match,
}
metrics = {
"event_count": len(event_rows),
"tool_call_started": sum(len(items) for items in starts.values()),
"tool_call_completed": sum(len(items) for items in completions.values()),
"tool_call_ids": len(all_call_ids),
"authorized_tool_count": len(allowed_tools),
"tool_authorization_mode": tool_authorization_mode,
"runner_native_tool_call_count": len(unauthorized_calls) if tool_authorization_mode == "runner-native" else 0,
"invalid_event_json": invalid_event_json,
"suspicious_error_count": len(suspicious_errors),
"recovered_tool_argument_error_count": len(recovered_argument_warnings),
'event_count': len(event_rows),
'tool_call_started': sum(len(items) for items in starts.values()),
'tool_call_completed': sum(len(items) for items in completions.values()),
'tool_call_ids': len(all_call_ids),
'authorized_tool_count': len(allowed_tools),
'tool_authorization_mode': tool_authorization_mode,
'runner_native_tool_call_count': len(unauthorized_calls) if tool_authorization_mode == 'runner-native' else 0,
'invalid_event_json': invalid_event_json,
'suspicious_error_count': len(suspicious_errors),
'recovered_tool_argument_error_count': len(recovered_argument_warnings),
}
return {
"status": "pass" if not failures else "fail",
"reason": "Agent run ledger audit passed." if not failures else f"Agent run ledger audit found {len(failures)} invariant failure(s).",
"run": {
"run_id": selected_run_id,
"runner_id": run_row["runner_id"],
"status": run_row["status"],
"created_at": str(run_row["created_at"]),
"finished_at": str(run_row["finished_at"]),
'status': 'pass' if not failures else 'fail',
'reason': 'Agent run ledger audit passed.'
if not failures
else f'Agent run ledger audit found {len(failures)} invariant failure(s).',
'run': {
'run_id': selected_run_id,
'runner_id': run_row['runner_id'],
'status': run_row['status'],
'created_at': str(run_row['created_at']),
'finished_at': str(run_row['finished_at']),
},
"metrics": metrics,
"expected_tool_call": expected_call_summary,
"failures": failures,
"warnings": warnings,
'metrics': metrics,
'expected_tool_call': expected_call_summary,
'failures': failures,
'warnings': warnings,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", required=True)
parser.add_argument("--run-id")
parser.add_argument("--created-after")
parser.add_argument("--expected-tool-name")
parser.add_argument("--expected-parameters-json")
parser.add_argument("--expected-result-text")
parser.add_argument('--repo', required=True)
parser.add_argument('--run-id')
parser.add_argument('--created-after')
parser.add_argument('--expected-tool-name')
parser.add_argument('--expected-parameters-json')
parser.add_argument('--expected-result-text')
parser.add_argument(
"--tool-authorization-mode",
choices=("strict", "runner-native"),
default="strict",
'--tool-authorization-mode',
choices=('strict', 'runner-native'),
default='strict',
)
parser.add_argument("--output", required=True)
parser.add_argument('--output', required=True)
args = parser.parse_args()
try:
expected_parameters = None
if args.expected_parameters_json:
expected_parameters = json.loads(args.expected_parameters_json)
if not isinstance(expected_parameters, dict):
raise ValueError("--expected-parameters-json must decode to an object")
raise ValueError('--expected-parameters-json must decode to an object')
if (expected_parameters is not None or args.expected_result_text) and not args.expected_tool_name:
raise ValueError("--expected-tool-name is required with expected parameters or result text")
report = asyncio.run(audit(
pathlib.Path(args.repo).resolve(),
args.run_id,
created_after=parse_created_after(args.created_after),
expected_tool_name=args.expected_tool_name,
expected_parameters=expected_parameters,
expected_result_text=args.expected_result_text,
tool_authorization_mode=args.tool_authorization_mode,
))
raise ValueError('--expected-tool-name is required with expected parameters or result text')
report = asyncio.run(
audit(
pathlib.Path(args.repo).resolve(),
args.run_id,
created_after=parse_created_after(args.created_after),
expected_tool_name=args.expected_tool_name,
expected_parameters=expected_parameters,
expected_result_text=args.expected_result_text,
tool_authorization_mode=args.tool_authorization_mode,
)
)
except Exception as exc: # noqa: BLE001 - probe must classify environment failures
report = {"status": "env_issue", "reason": str(exc), "failures": [], "warnings": []}
pathlib.Path(args.output).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
report = {'status': 'env_issue', 'reason': str(exc), 'failures': [], 'warnings': []}
pathlib.Path(args.output).write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report))
return 0 if report["status"] == "pass" else 2 if report["status"] == "env_issue" else 1
return 0 if report['status'] == 'pass' else 2 if report['status'] == 'env_issue' else 1
if __name__ == "__main__":
if __name__ == '__main__':
sys.exit(main())
@@ -143,7 +143,7 @@ try {
}
if (!runner?.name) {
result.status = "blocked";
throw new Error("No registered AgentRunner is available for the UI check.");
throw new Error("No registered Runner is available for the UI check.");
}
const runnerConfigStage = runnerTab.stages.find(
@@ -155,7 +155,7 @@ try {
body: {
kind: "agent",
name: `Runner Health ${paths.runId.slice(-40)}`,
description: "Temporary AgentRunner health visibility fixture",
description: "Temporary Runner health visibility fixture",
emoji: "H",
component_ref: runner.name,
config: {
@@ -229,7 +229,7 @@ try {
}
result.status = "pass";
result.reason =
"Agent Runner settings visibly distinguished a registered runner from a stale binding.";
"Runner settings visibly distinguished a registered runner from a stale binding.";
} catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message;
@@ -24,7 +24,10 @@ function loadEnvDefaults(path) {
if (sep === -1) continue;
const key = line.slice(0, sep).trim();
if (env[key]) continue;
env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, "");
env[key] = line
.slice(sep + 1)
.trim()
.replace(/^["']|["']$/g, "");
}
}
@@ -46,12 +49,16 @@ function redactMessage(text) {
return String(text ?? "")
.replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]")
.replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")
.replace(/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi, "$1=[redacted]");
.replace(
/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi,
"$1=[redacted]",
);
}
function isEnvironmentError(message) {
return /Playwright is not installed|LANGBOT_FRONTEND_URL|LANGBOT_BACKEND_URL|ERR_CONNECTION_REFUSED|ECONNREFUSED|net::ERR_|fetch failed|timed out/i
.test(message);
return /Playwright is not installed|LANGBOT_FRONTEND_URL|LANGBOT_BACKEND_URL|ERR_CONNECTION_REFUSED|ECONNREFUSED|net::ERR_|fetch failed|timed out/i.test(
message,
);
}
loadEnvDefaults("skills/.env");
@@ -80,9 +87,15 @@ const targets = [
},
{
id: "acp-agent-runner",
expected_runner_id: "plugin:langbot-team/ACPAgentRunner/default",
pipeline_url: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", "LANGBOT_AGENT_RUNNER_PIPELINE_URL"),
pipeline_name: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", "LANGBOT_AGENT_RUNNER_PIPELINE_NAME"),
expected_runner_id: "plugin:langbot-team/ACPRunner/default",
pipeline_url: firstEnv(
"LANGBOT_ACP_RUNNER_PIPELINE_URL",
"LANGBOT_RUNNER_PIPELINE_URL",
),
pipeline_name: firstEnv(
"LANGBOT_ACP_RUNNER_PIPELINE_NAME",
"LANGBOT_RUNNER_PIPELINE_NAME",
),
require_func_call_model: false,
require_vision_model: false,
},
@@ -111,20 +124,29 @@ const result = {
automation_result_json: paths.automationResultJson,
result_json: paths.resultJson,
},
evidence_collected: ["ui", "screenshot", "console", "network", "api_diagnostic"],
evidence_collected: [
"ui",
"screenshot",
"console",
"network",
"api_diagnostic",
],
};
async function run() {
if (!backendUrl || !frontendUrl) {
result.status = "env_issue";
result.reason = "LANGBOT_FRONTEND_URL and LANGBOT_BACKEND_URL must be configured.";
result.reason =
"LANGBOT_FRONTEND_URL and LANGBOT_BACKEND_URL must be configured.";
return;
}
browser = await createBrowser(paths);
const { page } = browser;
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
await page
.waitForLoadState("networkidle", { timeout: 10_000 })
.catch(() => {});
const workspace = await ensureBrowserWorkspace(page, backendUrl);
if (workspace.status !== "pass") {
result.status = workspace.status;
@@ -132,309 +154,428 @@ async function run() {
return;
}
const diagnostic = await page.evaluate(async ({ backendUrl, targets, testModels }) => {
const blockers = [];
const envIssues = [];
const warnings = [];
const checks = [];
const diagnostic = await page.evaluate(
async ({ backendUrl, targets, testModels }) => {
const blockers = [];
const envIssues = [];
const warnings = [];
const checks = [];
const addCheck = (name, status, detail = {}) => {
checks.push({ name, status, ...detail });
if (status === "blocked") blockers.push({ name, ...detail });
if (status === "env_issue") envIssues.push({ name, ...detail });
};
const safeMessage = (value) => String(value ?? "")
.replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]")
.replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")
.replace(/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi, "$1=[redacted]");
const token = localStorage.getItem("token");
if (!token) {
addCheck("browser-auth", "blocked", { reason: "Browser profile has no localStorage token." });
return { authenticated: false, blockers, env_issues: envIssues, warnings, checks };
}
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
};
const getJson = async (path) => {
const response = await fetch(`${backendUrl}${path}`, { headers });
return {
status: response.status,
json: await response.json().catch(() => ({})),
const addCheck = (name, status, detail = {}) => {
checks.push({ name, status, ...detail });
if (status === "blocked") blockers.push({ name, ...detail });
if (status === "env_issue") envIssues.push({ name, ...detail });
};
};
const postJson = async (path, body) => {
const response = await fetch(`${backendUrl}${path}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return {
status: response.status,
json: await response.json().catch(() => ({})),
};
};
const safeMessage = (value) =>
String(value ?? "")
.replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]")
.replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")
.replace(
/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi,
"$1=[redacted]",
);
const tokenCheck = await getJson("/api/v1/user/check-token");
addCheck(
"browser-auth",
tokenCheck.status < 400 && (tokenCheck.json.code ?? 0) === 0 ? "pass" : "blocked",
{ http_status: tokenCheck.status, code: tokenCheck.json.code ?? null, reason: safeMessage(tokenCheck.json.msg || "") },
);
const systemInfo = await getJson("/api/v1/system/info");
addCheck(
"backend-system-info",
systemInfo.status < 400 ? "pass" : "env_issue",
{
http_status: systemInfo.status,
version: systemInfo.json.data?.version || systemInfo.json.data?.system?.version || "",
},
);
const pluginSystem = await getJson("/api/v1/system/status/plugin-system");
addCheck(
"plugin-system",
pluginSystem.status < 400 && (pluginSystem.json.code ?? 0) === 0 ? "pass" : "env_issue",
{
http_status: pluginSystem.status,
code: pluginSystem.json.code ?? null,
status: pluginSystem.json.data?.status || pluginSystem.json.data?.state || "",
reason: safeMessage(pluginSystem.json.msg || ""),
},
);
const boxStatus = await getJson("/api/v1/box/status");
addCheck(
"box-runtime",
boxStatus.status < 400 && (boxStatus.json.code ?? 0) === 0 ? "pass" : "env_issue",
{
http_status: boxStatus.status,
code: boxStatus.json.code ?? null,
status: boxStatus.json.data?.status || "",
backend: boxStatus.json.data?.backend || "",
reason: safeMessage(boxStatus.json.msg || ""),
},
);
const plugins = await getJson("/api/v1/plugins");
const installedPluginIds = (plugins.json.data?.plugins || [])
.map((plugin) => {
const metadata = plugin.manifest?.manifest?.metadata || plugin.manifest?.metadata || plugin.metadata || {};
return metadata.author && metadata.name ? `${metadata.author}/${metadata.name}` : "";
})
.filter(Boolean);
const requiredPlugins = ["langbot-team/LocalAgent", "langbot-team/ACPAgentRunner", "qa/plugin-smoke"];
const pluginPresence = Object.fromEntries(requiredPlugins.map((id) => [id, installedPluginIds.includes(id)]));
for (const [id, present] of Object.entries(pluginPresence)) {
addCheck(`plugin:${id}`, present ? "pass" : "blocked", { plugin_id: id, reason: present ? "" : "Required plugin is not listed by /api/v1/plugins." });
}
const tools = await getJson("/api/v1/tools");
const toolNames = (tools.json.data?.tools || [])
.map((tool) => tool.name || tool.tool_name || tool.function?.name || "")
.filter(Boolean)
.sort();
addCheck(
"tool:qa_plugin_echo",
toolNames.includes("qa_plugin_echo") ? "pass" : "blocked",
{ reason: toolNames.includes("qa_plugin_echo") ? "" : "qa-plugin-smoke tool qa_plugin_echo is not exposed through /api/v1/tools." },
);
if (!toolNames.includes("qa_mcp_echo")) {
warnings.push({
name: "tool:qa_mcp_echo",
reason: "qa_mcp_echo is not currently exposed. This is acceptable before mcp-stdio-register, but mcp-stdio-tool-call must run after registration.",
});
}
const modelResponse = await getJson("/api/v1/provider/models/llm");
const models = (modelResponse.json.data?.models || []).map((model) => ({
uuid: model.uuid,
name: model.name,
abilities: Array.isArray(model.abilities) ? model.abilities : [],
provider_uuid: model.provider_uuid || model.provider?.uuid || "",
provider_name: model.provider_name || model.provider?.name || "",
requester: model.requester || model.provider?.requester || "",
}));
addCheck(
"llm-model-list",
modelResponse.status < 400 && (modelResponse.json.code ?? 0) === 0 ? "pass" : "env_issue",
{ http_status: modelResponse.status, model_count: models.length, reason: safeMessage(modelResponse.json.msg || "") },
);
const modelById = new Map(models.map((model) => [model.uuid, model]));
const pipelineList = await getJson("/api/v1/pipelines");
const pipelines = pipelineList.json.data?.pipelines || [];
addCheck(
"pipeline-list",
pipelineList.status < 400 && (pipelineList.json.code ?? 0) === 0 ? "pass" : "blocked",
{ http_status: pipelineList.status, pipeline_count: pipelines.length, reason: safeMessage(pipelineList.json.msg || "") },
);
const resolvedPipelines = [];
const modelTested = new Set();
for (const target of targets) {
let pipelineId = "";
let matchedBy = "";
if (target.pipeline_url) {
try {
pipelineId = new URL(target.pipeline_url).searchParams.get("id") || "";
matchedBy = pipelineId ? "url" : "";
} catch {
pipelineId = "";
}
}
if (!pipelineId && target.pipeline_name) {
const match = pipelines.find((pipeline) => pipeline.name === target.pipeline_name);
if (match) {
pipelineId = match.uuid;
matchedBy = "name";
}
}
if (!pipelineId) {
addCheck(`pipeline:${target.id}`, "blocked", {
target: target.id,
reason: "Required pipeline env is missing or could not resolve to a pipeline id.",
const token = localStorage.getItem("token");
if (!token) {
addCheck("browser-auth", "blocked", {
reason: "Browser profile has no localStorage token.",
});
continue;
return {
authenticated: false,
blockers,
env_issues: envIssues,
warnings,
checks,
};
}
const response = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`);
const pipeline = response.json.data?.pipeline;
if (response.status >= 400 || !pipeline) {
addCheck(`pipeline:${target.id}`, "blocked", {
target: target.id,
pipeline_id: pipelineId,
http_status: response.status,
reason: safeMessage(response.json.msg || "Could not load pipeline."),
});
continue;
}
const config = pipeline.config || {};
const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {};
const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {};
const runnerId = runner.id || "";
const runnerConfigs = aiConfig.runner_config && typeof aiConfig.runner_config === "object" ? aiConfig.runner_config : {};
const runnerConfig = runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" ? runnerConfigs[runnerId] : {};
const pipelineSummary = {
target: target.id,
pipeline_id: pipelineId,
pipeline_name: pipeline.name,
matched_by: matchedBy,
runner_id: runnerId,
expected_runner_id: target.expected_runner_id,
runner_config_keys: Object.keys(runnerConfig).sort(),
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id":
localStorage.getItem("langbot_active_workspace_uuid") || "",
};
const getJson = async (path) => {
const response = await fetch(`${backendUrl}${path}`, { headers });
return {
status: response.status,
json: await response.json().catch(() => ({})),
};
};
const postJson = async (path, body) => {
const response = await fetch(`${backendUrl}${path}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return {
status: response.status,
json: await response.json().catch(() => ({})),
};
};
resolvedPipelines.push(pipelineSummary);
const tokenCheck = await getJson("/api/v1/user/check-token");
addCheck(
`pipeline:${target.id}:runner`,
runnerId === target.expected_runner_id ? "pass" : "blocked",
"browser-auth",
tokenCheck.status < 400 && (tokenCheck.json.code ?? 0) === 0
? "pass"
: "blocked",
{
...pipelineSummary,
reason: runnerId === target.expected_runner_id ? "" : `Expected ${target.expected_runner_id}, got ${runnerId || "<missing>"}.`,
http_status: tokenCheck.status,
code: tokenCheck.json.code ?? null,
reason: safeMessage(tokenCheck.json.msg || ""),
},
);
if (target.require_func_call_model || target.require_vision_model || (testModels && target.id === "local-agent")) {
const modelConfig = runnerConfig.model;
const primaryModelId = typeof modelConfig === "string"
? modelConfig
: modelConfig && typeof modelConfig === "object"
? modelConfig.primary || ""
const systemInfo = await getJson("/api/v1/system/info");
addCheck(
"backend-system-info",
systemInfo.status < 400 ? "pass" : "env_issue",
{
http_status: systemInfo.status,
version:
systemInfo.json.data?.version ||
systemInfo.json.data?.system?.version ||
"",
},
);
const pluginSystem = await getJson("/api/v1/system/status/plugin-system");
addCheck(
"plugin-system",
pluginSystem.status < 400 && (pluginSystem.json.code ?? 0) === 0
? "pass"
: "env_issue",
{
http_status: pluginSystem.status,
code: pluginSystem.json.code ?? null,
status:
pluginSystem.json.data?.status ||
pluginSystem.json.data?.state ||
"",
reason: safeMessage(pluginSystem.json.msg || ""),
},
);
const boxStatus = await getJson("/api/v1/box/status");
addCheck(
"box-runtime",
boxStatus.status < 400 && (boxStatus.json.code ?? 0) === 0
? "pass"
: "env_issue",
{
http_status: boxStatus.status,
code: boxStatus.json.code ?? null,
status: boxStatus.json.data?.status || "",
backend: boxStatus.json.data?.backend || "",
reason: safeMessage(boxStatus.json.msg || ""),
},
);
const plugins = await getJson("/api/v1/plugins");
const installedPluginIds = (plugins.json.data?.plugins || [])
.map((plugin) => {
const metadata =
plugin.manifest?.manifest?.metadata ||
plugin.manifest?.metadata ||
plugin.metadata ||
{};
return metadata.author && metadata.name
? `${metadata.author}/${metadata.name}`
: "";
if (!primaryModelId) {
addCheck(`pipeline:${target.id}:primary-model`, "blocked", {
...pipelineSummary,
reason: "Local-agent runner config has no primary model.",
});
continue;
}
const model = modelById.get(primaryModelId);
if (!model) {
addCheck(`pipeline:${target.id}:primary-model`, "blocked", {
...pipelineSummary,
model_uuid: primaryModelId,
reason: "Primary model is not listed by /api/v1/provider/models/llm.",
});
continue;
}
addCheck(`pipeline:${target.id}:primary-model`, "pass", {
...pipelineSummary,
model: {
uuid: model.uuid,
name: model.name,
abilities: model.abilities,
provider_name: model.provider_name,
requester: model.requester,
},
})
.filter(Boolean);
const requiredPlugins = [
"langbot-team/LocalAgent",
"langbot-team/ACPRunner",
"qa/plugin-smoke",
];
const pluginPresence = Object.fromEntries(
requiredPlugins.map((id) => [id, installedPluginIds.includes(id)]),
);
for (const [id, present] of Object.entries(pluginPresence)) {
addCheck(`plugin:${id}`, present ? "pass" : "blocked", {
plugin_id: id,
reason: present
? ""
: "Required plugin is not listed by /api/v1/plugins.",
});
if (target.require_func_call_model) {
addCheck(
`pipeline:${target.id}:func-call-model`,
model.abilities.includes("func_call") ? "pass" : "env_issue",
{
model_uuid: model.uuid,
model_name: model.name,
abilities: model.abilities,
reason: model.abilities.includes("func_call") ? "" : "Release gate includes tool-call cases; the local-agent primary model must advertise func_call.",
},
);
}
const tools = await getJson("/api/v1/tools");
const toolNames = (tools.json.data?.tools || [])
.map((tool) => tool.name || tool.tool_name || tool.function?.name || "")
.filter(Boolean)
.sort();
addCheck(
"tool:qa_plugin_echo",
toolNames.includes("qa_plugin_echo") ? "pass" : "blocked",
{
reason: toolNames.includes("qa_plugin_echo")
? ""
: "qa-plugin-smoke tool qa_plugin_echo is not exposed through /api/v1/tools.",
},
);
if (!toolNames.includes("qa_mcp_echo")) {
warnings.push({
name: "tool:qa_mcp_echo",
reason:
"qa_mcp_echo is not currently exposed. This is acceptable before mcp-stdio-register, but mcp-stdio-tool-call must run after registration.",
});
}
const modelResponse = await getJson("/api/v1/provider/models/llm");
const models = (modelResponse.json.data?.models || []).map((model) => ({
uuid: model.uuid,
name: model.name,
abilities: Array.isArray(model.abilities) ? model.abilities : [],
provider_uuid: model.provider_uuid || model.provider?.uuid || "",
provider_name: model.provider_name || model.provider?.name || "",
requester: model.requester || model.provider?.requester || "",
}));
addCheck(
"llm-model-list",
modelResponse.status < 400 && (modelResponse.json.code ?? 0) === 0
? "pass"
: "env_issue",
{
http_status: modelResponse.status,
model_count: models.length,
reason: safeMessage(modelResponse.json.msg || ""),
},
);
const modelById = new Map(models.map((model) => [model.uuid, model]));
const pipelineList = await getJson("/api/v1/pipelines");
const pipelines = pipelineList.json.data?.pipelines || [];
addCheck(
"pipeline-list",
pipelineList.status < 400 && (pipelineList.json.code ?? 0) === 0
? "pass"
: "blocked",
{
http_status: pipelineList.status,
pipeline_count: pipelines.length,
reason: safeMessage(pipelineList.json.msg || ""),
},
);
const resolvedPipelines = [];
const modelTested = new Set();
for (const target of targets) {
let pipelineId = "";
let matchedBy = "";
if (target.pipeline_url) {
try {
pipelineId =
new URL(target.pipeline_url).searchParams.get("id") || "";
matchedBy = pipelineId ? "url" : "";
} catch {
pipelineId = "";
}
}
if (target.require_vision_model) {
addCheck(
`pipeline:${target.id}:vision-model`,
model.abilities.includes("vision") ? "pass" : "env_issue",
{
model_uuid: model.uuid,
model_name: model.name,
abilities: model.abilities,
reason: model.abilities.includes("vision") ? "" : "Release gate includes multimodal cases; the local-agent primary model must advertise vision.",
},
if (!pipelineId && target.pipeline_name) {
const match = pipelines.find(
(pipeline) => pipeline.name === target.pipeline_name,
);
if (match) {
pipelineId = match.uuid;
matchedBy = "name";
}
}
if (testModels && !modelTested.has(model.uuid)) {
modelTested.add(model.uuid);
const modelTest = await postJson(`/api/v1/provider/models/llm/${encodeURIComponent(model.uuid)}/test`, { extra_args: {} });
const passed = modelTest.status < 400 && (modelTest.json.code ?? 0) === 0;
addCheck(
`model-test:${model.name}`,
passed ? "pass" : "env_issue",
{
model_uuid: model.uuid,
model_name: model.name,
http_status: modelTest.status,
code: modelTest.json.code ?? null,
reason: passed ? "" : safeMessage(modelTest.json.msg || modelTest.json.message || "Model test failed."),
if (!pipelineId) {
addCheck(`pipeline:${target.id}`, "blocked", {
target: target.id,
reason:
"Required pipeline env is missing or could not resolve to a pipeline id.",
});
continue;
}
const response = await getJson(
`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`,
);
const pipeline = response.json.data?.pipeline;
if (response.status >= 400 || !pipeline) {
addCheck(`pipeline:${target.id}`, "blocked", {
target: target.id,
pipeline_id: pipelineId,
http_status: response.status,
reason: safeMessage(
response.json.msg || "Could not load pipeline.",
),
});
continue;
}
const config = pipeline.config || {};
const aiConfig =
config.ai && typeof config.ai === "object" ? config.ai : {};
const runner =
aiConfig.runner && typeof aiConfig.runner === "object"
? aiConfig.runner
: {};
const runnerId = runner.id || "";
const runnerConfigs =
aiConfig.runner_config && typeof aiConfig.runner_config === "object"
? aiConfig.runner_config
: {};
const runnerConfig =
runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object"
? runnerConfigs[runnerId]
: {};
const pipelineSummary = {
target: target.id,
pipeline_id: pipelineId,
pipeline_name: pipeline.name,
matched_by: matchedBy,
runner_id: runnerId,
expected_runner_id: target.expected_runner_id,
runner_config_keys: Object.keys(runnerConfig).sort(),
};
resolvedPipelines.push(pipelineSummary);
addCheck(
`pipeline:${target.id}:runner`,
runnerId === target.expected_runner_id ? "pass" : "blocked",
{
...pipelineSummary,
reason:
runnerId === target.expected_runner_id
? ""
: `Expected ${target.expected_runner_id}, got ${runnerId || "<missing>"}.`,
},
);
if (
target.require_func_call_model ||
target.require_vision_model ||
(testModels && target.id === "local-agent")
) {
const modelConfig = runnerConfig.model;
const primaryModelId =
typeof modelConfig === "string"
? modelConfig
: modelConfig && typeof modelConfig === "object"
? modelConfig.primary || ""
: "";
if (!primaryModelId) {
addCheck(`pipeline:${target.id}:primary-model`, "blocked", {
...pipelineSummary,
reason: "Local-agent runner config has no primary model.",
});
continue;
}
const model = modelById.get(primaryModelId);
if (!model) {
addCheck(`pipeline:${target.id}:primary-model`, "blocked", {
...pipelineSummary,
model_uuid: primaryModelId,
reason:
"Primary model is not listed by /api/v1/provider/models/llm.",
});
continue;
}
addCheck(`pipeline:${target.id}:primary-model`, "pass", {
...pipelineSummary,
model: {
uuid: model.uuid,
name: model.name,
abilities: model.abilities,
provider_name: model.provider_name,
requester: model.requester,
},
);
});
if (target.require_func_call_model) {
addCheck(
`pipeline:${target.id}:func-call-model`,
model.abilities.includes("func_call") ? "pass" : "env_issue",
{
model_uuid: model.uuid,
model_name: model.name,
abilities: model.abilities,
reason: model.abilities.includes("func_call")
? ""
: "Release gate includes tool-call cases; the local-agent primary model must advertise func_call.",
},
);
}
if (target.require_vision_model) {
addCheck(
`pipeline:${target.id}:vision-model`,
model.abilities.includes("vision") ? "pass" : "env_issue",
{
model_uuid: model.uuid,
model_name: model.name,
abilities: model.abilities,
reason: model.abilities.includes("vision")
? ""
: "Release gate includes multimodal cases; the local-agent primary model must advertise vision.",
},
);
}
if (testModels && !modelTested.has(model.uuid)) {
modelTested.add(model.uuid);
const modelTest = await postJson(
`/api/v1/provider/models/llm/${encodeURIComponent(model.uuid)}/test`,
{ extra_args: {} },
);
const passed =
modelTest.status < 400 && (modelTest.json.code ?? 0) === 0;
addCheck(
`model-test:${model.name}`,
passed ? "pass" : "env_issue",
{
model_uuid: model.uuid,
model_name: model.name,
http_status: modelTest.status,
code: modelTest.json.code ?? null,
reason: passed
? ""
: safeMessage(
modelTest.json.msg ||
modelTest.json.message ||
"Model test failed.",
),
},
);
}
}
}
}
return {
authenticated: true,
blockers,
env_issues: envIssues,
warnings,
checks,
resolved_pipelines: resolvedPipelines,
tools: {
required: ["qa_plugin_echo"],
optional_before_register: ["qa_mcp_echo"],
present: toolNames.filter((name) => ["qa_plugin_echo", "qa_mcp_echo"].includes(name)),
},
models,
};
}, { backendUrl, targets, testModels });
return {
authenticated: true,
blockers,
env_issues: envIssues,
warnings,
checks,
resolved_pipelines: resolvedPipelines,
tools: {
required: ["qa_plugin_echo"],
optional_before_register: ["qa_mcp_echo"],
present: toolNames.filter((name) =>
["qa_plugin_echo", "qa_mcp_echo"].includes(name),
),
},
models,
};
},
{ backendUrl, targets, testModels },
);
diagnostic.blockers = (diagnostic.blockers || []).map((item) => ({ ...item, reason: redactMessage(item.reason || "") }));
diagnostic.env_issues = (diagnostic.env_issues || []).map((item) => ({ ...item, reason: redactMessage(item.reason || "") }));
await writeFile(diagnosticPath, `${JSON.stringify(diagnostic, null, 2)}\n`, "utf8");
diagnostic.blockers = (diagnostic.blockers || []).map((item) => ({
...item,
reason: redactMessage(item.reason || ""),
}));
diagnostic.env_issues = (diagnostic.env_issues || []).map((item) => ({
...item,
reason: redactMessage(item.reason || ""),
}));
await writeFile(
diagnosticPath,
`${JSON.stringify(diagnostic, null, 2)}\n`,
"utf8",
);
await safeScreenshot(page, paths.screenshot);
const blockers = diagnostic.blockers || [];
@@ -447,31 +588,49 @@ async function run() {
result.reason = `Preflight environment issue: ${envIssues.map((item) => item.name).join(", ")}`;
} else {
result.status = "pass";
result.reason = "Release gate preflight passed: auth, plugin runtime, required pipelines, runner ids, tools, and local-agent model checks are ready.";
result.reason =
"Release gate preflight passed: auth, plugin runtime, required pipelines, runner ids, tools, and local-agent model checks are ready.";
}
result.check_count = Array.isArray(diagnostic.checks) ? diagnostic.checks.length : 0;
result.warning_count = Array.isArray(diagnostic.warnings) ? diagnostic.warnings.length : 0;
result.check_count = Array.isArray(diagnostic.checks)
? diagnostic.checks.length
: 0;
result.warning_count = Array.isArray(diagnostic.warnings)
? diagnostic.warnings.length
: 0;
}
try {
await run();
} catch (error) {
const message = redactMessage(error instanceof Error ? error.message : String(error));
const message = redactMessage(
error instanceof Error ? error.message : String(error),
);
result.status = isEnvironmentError(message) ? "env_issue" : "fail";
result.reason = message;
await writeFile(diagnosticPath, `${JSON.stringify({
authenticated: false,
blockers: [],
env_issues: result.status === "env_issue" ? [{ name: "preflight-runtime", reason: message }] : [],
warnings: [],
checks: [
await writeFile(
diagnosticPath,
`${JSON.stringify(
{
name: "preflight-runtime",
status: result.status,
reason: message,
authenticated: false,
blockers: [],
env_issues:
result.status === "env_issue"
? [{ name: "preflight-runtime", reason: message }]
: [],
warnings: [],
checks: [
{
name: "preflight-runtime",
status: result.status,
reason: message,
},
],
},
],
}, null, 2)}\n`, "utf8").catch(() => {});
null,
2,
)}\n`,
"utf8",
).catch(() => {});
} finally {
if (browser) await browser.close().catch(() => {});
const finishedAt = new Date();
+14 -15
View File
@@ -1,4 +1,4 @@
"""Policy helpers for classifying AgentRunner ledger error signals."""
"""Policy helpers for classifying Runner ledger error signals."""
from __future__ import annotations
@@ -7,7 +7,7 @@ import re
_INVALID_TOOL_ARGUMENT_PATTERN = re.compile(
r"invalid json arguments|\b\d+\s+validation errors?\s+for\s+[A-Za-z_][A-Za-z0-9_]*Args\b",
r'invalid json arguments|\b\d+\s+validation errors?\s+for\s+[A-Za-z_][A-Za-z0-9_]*Args\b',
re.IGNORECASE,
)
@@ -19,14 +19,14 @@ def load_ledger_json(value: str | None, *, field: str, failures: list[dict]) ->
try:
return json.loads(value)
except (TypeError, ValueError) as exc:
failures.append({"kind": "invalid_json", "field": field, "reason": str(exc)})
failures.append({'kind': 'invalid_json', 'field': field, 'reason': str(exc)})
return {}
def invalid_tool_argument_error_signal(value: str) -> str:
"""Return the persisted signal for malformed model-supplied tool arguments."""
match = _INVALID_TOOL_ARGUMENT_PATTERN.search(value)
return match.group(0) if match else ""
return match.group(0) if match else ''
def classify_invalid_tool_argument_errors(
@@ -40,15 +40,14 @@ def classify_invalid_tool_argument_errors(
warnings: list[dict] = []
for event in events:
recovered = run_completed and any(
sequence > event["sequence"]
for sequence in successful_tool_completion_sequences
sequence > event['sequence'] for sequence in successful_tool_completion_sequences
)
if recovered:
warnings.append(
{
"kind": "recovered_tool_argument_error",
"event": event,
"reason": "The model continued with a later successful tool call and the run completed.",
'kind': 'recovered_tool_argument_error',
'event': event,
'reason': 'The model continued with a later successful tool call and the run completed.',
}
)
else:
@@ -64,15 +63,15 @@ def classify_tool_authorization(
"""Classify tool names absent from the Host authorization snapshot."""
if not calls:
return [], []
if authorization_mode == "runner-native":
if authorization_mode == 'runner-native':
return [], [
{
"kind": "runner_native_tool_calls",
"calls": calls,
"reason": (
"External runner tool telemetry is not a LangBot Host tool call; "
'kind': 'runner_native_tool_calls',
'calls': calls,
'reason': (
'External runner tool telemetry is not a LangBot Host tool call; '
"the runner's own permission system governs it."
),
}
]
return [{"kind": "unauthorized_tool_calls", "calls": calls}], []
return [{'kind': 'unauthorized_tool_calls', 'calls': calls}], []
@@ -12,7 +12,7 @@ import {
writeResult,
} from "./lib/langbot-e2e.mjs";
const RUNNER_ID = "plugin:langbot-team/ACPAgentRunner/default";
const RUNNER_ID = "plugin:langbot-team/ACPRunner/default";
const DEFAULT_PIPELINE_NAME = "Agent QA ACP Claude Debug Chat";
const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026";
const caseId = "ensure-acp-agent-runner-pipeline";
@@ -24,13 +24,18 @@ await ensureEvidence(paths);
const writeEnv = process.argv.includes("--write-env");
const frontendUrl = env.LANGBOT_FRONTEND_URL || "";
const backendUrl = env.LANGBOT_BACKEND_URL || "";
const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME;
const sshTarget = env.LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET || "yhh@101.34.71.12";
const sshConnectTimeout = env.LANGBOT_ACP_AGENT_RUNNER_SSH_CONNECT_TIMEOUT || "8";
const sshPort = env.LANGBOT_ACP_AGENT_RUNNER_SSH_PORT || "22";
const sshIdentityFile = env.LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE || "";
const sshExtraOptions = env.LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS || "";
const remoteWorkspace = env.LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE || "/home/yhh/langbot-e2e/acp-workspace";
const pipelineName =
env.LANGBOT_E2E_CREATE_PIPELINE_NAME ||
env.LANGBOT_ACP_RUNNER_PIPELINE_NAME ||
DEFAULT_PIPELINE_NAME;
const sshTarget = env.LANGBOT_ACP_RUNNER_SSH_TARGET || "yhh@101.34.71.12";
const sshConnectTimeout = env.LANGBOT_ACP_RUNNER_SSH_CONNECT_TIMEOUT || "8";
const sshPort = env.LANGBOT_ACP_RUNNER_SSH_PORT || "22";
const sshIdentityFile = env.LANGBOT_ACP_RUNNER_SSH_IDENTITY_FILE || "";
const sshExtraOptions = env.LANGBOT_ACP_RUNNER_SSH_EXTRA_OPTIONS || "";
const remoteWorkspace =
env.LANGBOT_ACP_RUNNER_REMOTE_WORKSPACE ||
"/home/yhh/langbot-e2e/acp-workspace";
const envLocalPath = resolve("skills/.env.local");
const result = {
@@ -64,7 +69,9 @@ try {
const user = env.LANGBOT_E2E_LOGIN_USER || "";
const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD;
if (!user) {
throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API.");
throw new Error(
"LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API.",
);
}
const auth = await resetAndAuthLocalUser({ backendUrl, user, password });
@@ -116,13 +123,13 @@ try {
if (writeEnv && result.pipeline_id) {
await upsertEnvLocal(envLocalPath, {
LANGBOT_E2E_LOGIN_USER: user,
LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET: sshTarget,
LANGBOT_ACP_AGENT_RUNNER_SSH_PORT: sshPort,
LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE: sshIdentityFile,
LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS: sshExtraOptions,
LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE: remoteWorkspace,
LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL: result.pipeline_url,
LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName,
LANGBOT_ACP_RUNNER_SSH_TARGET: sshTarget,
LANGBOT_ACP_RUNNER_SSH_PORT: sshPort,
LANGBOT_ACP_RUNNER_SSH_IDENTITY_FILE: sshIdentityFile,
LANGBOT_ACP_RUNNER_SSH_EXTRA_OPTIONS: sshExtraOptions,
LANGBOT_ACP_RUNNER_REMOTE_WORKSPACE: remoteWorkspace,
LANGBOT_ACP_RUNNER_PIPELINE_URL: result.pipeline_url,
LANGBOT_ACP_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName,
});
result.wrote_env = true;
}
@@ -133,10 +140,20 @@ try {
console.log(JSON.stringify(result, null, 2));
}
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
process.exit(
result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1,
);
async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runnerConfig }) {
const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", { token });
async function ensurePipeline({
backendUrl,
token,
pipelineName,
runnerId,
runnerConfig,
}) {
const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", {
token,
});
if (isApiFailure(pipelineList)) {
return {
status: "fail",
@@ -155,7 +172,8 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
token,
body: {
name: pipelineName,
description: "Local QA pipeline for real ACP Claude AgentRunner Debug Chat smoke tests.",
description:
"Local QA pipeline for real ACP Claude Runner Debug Chat smoke tests.",
emoji: "QA",
},
});
@@ -167,7 +185,11 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
};
}
const pipelineId = createdResponse.json.data?.uuid || "";
const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token });
const loaded = await apiJson(
backendUrl,
`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`,
{ token },
);
pipeline = loaded.json.data?.pipeline || null;
created = true;
}
@@ -179,7 +201,11 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
};
}
const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token });
const loaded = await apiJson(
backendUrl,
`/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`,
{ token },
);
if (isApiFailure(loaded) || !loaded.json.data?.pipeline) {
return {
status: "fail",
@@ -190,9 +216,15 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
}
pipeline = loaded.json.data.pipeline;
const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {};
const config =
pipeline.config && typeof pipeline.config === "object"
? pipeline.config
: {};
const ai = config.ai && typeof config.ai === "object" ? config.ai : {};
const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object" ? ai.runner_config : {};
const runnerConfigs =
ai.runner_config && typeof ai.runner_config === "object"
? ai.runner_config
: {};
const updatedConfig = {
...config,
ai: {
@@ -209,16 +241,21 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
},
};
const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, {
method: "PUT",
token,
body: {
name: pipelineName,
description: "Local QA pipeline for real ACP Claude AgentRunner Debug Chat smoke tests.",
emoji: "QA",
config: updatedConfig,
const updateResponse = await apiJson(
backendUrl,
`/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`,
{
method: "PUT",
token,
body: {
name: pipelineName,
description:
"Local QA pipeline for real ACP Claude Runner Debug Chat smoke tests.",
emoji: "QA",
config: updatedConfig,
},
},
});
);
if (isApiFailure(updateResponse)) {
return {
status: "fail",
@@ -230,7 +267,9 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
return {
status: "pass",
reason: created ? "ACP AgentRunner pipeline created and configured." : "ACP AgentRunner pipeline updated.",
reason: created
? "ACP Runner pipeline created and configured."
: "ACP Runner pipeline updated.",
pipeline_id: pipeline.uuid,
pipeline_name: pipelineName,
created,
@@ -239,7 +278,12 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
}
function isApiFailure(response) {
return response.status >= 400 || (response.json && response.json.code !== undefined && response.json.code !== 0);
return (
response.status >= 400 ||
(response.json &&
response.json.code !== undefined &&
response.json.code !== 0)
);
}
async function upsertEnvLocal(path, values) {
@@ -102,14 +102,16 @@ try {
backend_token_check: auth.check,
};
const pluginSetup = await ensureLocalAgentRunner({
const pluginSetup = await ensureLocalRunner({
backendUrl,
token: auth.token,
});
result.plugin_setup = pluginSetup;
if (pluginSetup.status !== "pass") {
result.status = pluginSetup.status === "env_issue" ? "env_issue" : "fail";
throw new Error(pluginSetup.reason || "Failed to prepare the LocalAgent runner plugin.");
throw new Error(
pluginSetup.reason || "Failed to prepare the LocalAgent runner plugin.",
);
}
const wizard = await skipWizard({ backendUrl, token: auth.token });
@@ -205,7 +207,7 @@ async function skipWizard({ backendUrl, token }) {
};
}
async function ensureLocalAgentRunner({ backendUrl, token }) {
async function ensureLocalRunner({ backendUrl, token }) {
const [author, name] = RUNNER_ID.replace(/^plugin:/, "").split("/");
const existingRunnerIds = await listRunnerIds(backendUrl, token);
if (existingRunnerIds.includes(RUNNER_ID)) {
@@ -264,10 +266,9 @@ async function ensureLocalAgentRunner({ backendUrl, token }) {
};
}
const spaceUrl = String(env.LANGBOT_SPACE_URL || "https://space.langbot.app").replace(
/\/$/,
"",
);
const spaceUrl = String(
env.LANGBOT_SPACE_URL || "https://space.langbot.app",
).replace(/\/$/, "");
let detailResponse;
try {
detailResponse = await fetch(
@@ -376,7 +377,8 @@ async function waitForRunnerRegistration({
}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if ((await listRunnerIds(backendUrl, token)).includes(runnerId)) return true;
if ((await listRunnerIds(backendUrl, token)).includes(runnerId))
return true;
await sleep(1000);
}
return false;
@@ -501,8 +503,7 @@ async function ensureLocalAgentPipeline({
token,
body: {
name: pipelineName,
description:
"Local QA pipeline for AgentRunner Debug Chat smoke tests.",
description: "Local QA pipeline for Runner Debug Chat smoke tests.",
emoji: "QA",
},
});
@@ -640,8 +641,7 @@ async function ensureLocalAgentPipeline({
token,
body: {
name: pipelineName,
description:
"Local QA pipeline for AgentRunner Debug Chat smoke tests.",
description: "Local QA pipeline for Runner Debug Chat smoke tests.",
emoji: "QA",
config: updatedConfig,
},
@@ -24,7 +24,10 @@ await ensureEvidence(paths);
const writeEnv = process.argv.includes("--write-env");
const frontendUrl = env.LANGBOT_FRONTEND_URL || "";
const backendUrl = env.LANGBOT_BACKEND_URL || "";
const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME;
const pipelineName =
env.LANGBOT_E2E_CREATE_PIPELINE_NAME ||
env.LANGBOT_QA_RUNNER_PIPELINE_NAME ||
DEFAULT_PIPELINE_NAME;
const envLocalPath = resolve("skills/.env.local");
const result = {
@@ -55,7 +58,9 @@ try {
const user = env.LANGBOT_E2E_LOGIN_USER || "";
const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD;
if (!user) {
throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API.");
throw new Error(
"LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API.",
);
}
const auth = await resetAndAuthLocalUser({ backendUrl, user, password });
@@ -80,8 +85,8 @@ try {
if (writeEnv && result.pipeline_id) {
await upsertEnvLocal(envLocalPath, {
LANGBOT_E2E_LOGIN_USER: user,
LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL: result.pipeline_url,
LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName,
LANGBOT_QA_RUNNER_PIPELINE_URL: result.pipeline_url,
LANGBOT_QA_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName,
});
result.wrote_env = true;
}
@@ -92,10 +97,20 @@ try {
console.log(JSON.stringify(result, null, 2));
}
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
process.exit(
result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1,
);
async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runnerConfig }) {
const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", { token });
async function ensurePipeline({
backendUrl,
token,
pipelineName,
runnerId,
runnerConfig,
}) {
const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", {
token,
});
if (isApiFailure(pipelineList)) {
return {
status: "fail",
@@ -114,7 +129,8 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
token,
body: {
name: pipelineName,
description: "Local QA pipeline for deterministic QA AgentRunner Debug Chat smoke tests.",
description:
"Local QA pipeline for deterministic QA Runner Debug Chat smoke tests.",
emoji: "QA",
},
});
@@ -126,7 +142,11 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
};
}
const pipelineId = createdResponse.json.data?.uuid || "";
const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token });
const loaded = await apiJson(
backendUrl,
`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`,
{ token },
);
pipeline = loaded.json.data?.pipeline || null;
created = true;
}
@@ -138,7 +158,11 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
};
}
const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token });
const loaded = await apiJson(
backendUrl,
`/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`,
{ token },
);
if (isApiFailure(loaded) || !loaded.json.data?.pipeline) {
return {
status: "fail",
@@ -149,9 +173,15 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
}
pipeline = loaded.json.data.pipeline;
const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {};
const config =
pipeline.config && typeof pipeline.config === "object"
? pipeline.config
: {};
const ai = config.ai && typeof config.ai === "object" ? config.ai : {};
const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object" ? ai.runner_config : {};
const runnerConfigs =
ai.runner_config && typeof ai.runner_config === "object"
? ai.runner_config
: {};
const updatedConfig = {
...config,
ai: {
@@ -168,16 +198,21 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
},
};
const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, {
method: "PUT",
token,
body: {
name: pipelineName,
description: "Local QA pipeline for deterministic QA AgentRunner Debug Chat smoke tests.",
emoji: "QA",
config: updatedConfig,
const updateResponse = await apiJson(
backendUrl,
`/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`,
{
method: "PUT",
token,
body: {
name: pipelineName,
description:
"Local QA pipeline for deterministic QA Runner Debug Chat smoke tests.",
emoji: "QA",
config: updatedConfig,
},
},
});
);
if (isApiFailure(updateResponse)) {
return {
status: "fail",
@@ -189,7 +224,9 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
return {
status: "pass",
reason: created ? "QA AgentRunner pipeline created and configured." : "QA AgentRunner pipeline updated.",
reason: created
? "QA Runner pipeline created and configured."
: "QA Runner pipeline updated.",
pipeline_id: pipeline.uuid,
pipeline_name: pipelineName,
created,
@@ -198,7 +235,12 @@ async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runne
}
function isApiFailure(response) {
return response.status >= 400 || (response.json && response.json.code !== undefined && response.json.code !== 0);
return (
response.status >= 400 ||
(response.json &&
response.json.code !== undefined &&
response.json.code !== 0)
);
}
async function upsertEnvLocal(path, values) {
@@ -20,7 +20,10 @@ await loadEnvFiles();
const paths = evidencePaths(caseId);
await ensureEvidence(paths);
const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png");
const installedScreenshot = paths.screenshot.replace(/\.png$/, "-installed.png");
const installedScreenshot = paths.screenshot.replace(
/\.png$/,
"-installed.png",
);
const startedAt = new Date();
let frontendUrl = "";
@@ -84,7 +87,7 @@ try {
}
try {
const payload = request.postDataJSON();
if (payload?.component_filter === "AgentRunner") {
if (payload?.component_filter === "Runner") {
result.marketplace_request = {
endpoint: new URL(request.url()).pathname,
component_filter: payload.component_filter,
@@ -98,14 +101,17 @@ try {
});
page.on("response", async (response) => {
const pathname = new URL(response.url()).pathname;
if (!/\/api\/v1\/marketplace\/(extensions|plugins)\/search$/.test(pathname)) {
if (
!/\/api\/v1\/marketplace\/(extensions|plugins)\/search$/.test(pathname)
) {
return;
}
try {
const payload = await response.json();
const entries = payload?.data?.extensions || payload?.data?.plugins || [];
const localAgent = entries.find(
(entry) => `${entry.author}/${entry.name}` === "langbot-team/LocalAgent",
(entry) =>
`${entry.author}/${entry.name}` === "langbot-team/LocalAgent",
);
result.marketplace_response = {
endpoint: pathname,
@@ -228,7 +234,7 @@ try {
});
await browseLink.waitFor();
const href = await browseLink.getAttribute("href");
if (href !== "/home/extensions?type=plugin&component=AgentRunner") {
if (href !== "/home/extensions?type=plugin&component=Runner") {
throw new Error(`Unexpected Runner marketplace URL: ${href}`);
}
const nextButton = page.getByRole("button", {
@@ -238,9 +244,7 @@ try {
throw new Error("Wizard allowed continuing without an installed Runner.");
}
if (!result.marketplace_request) {
throw new Error(
"Wizard did not request the AgentRunner Marketplace catalog.",
);
throw new Error("Wizard did not request the Runner Marketplace catalog.");
}
if (
!result.marketplace_response?.local_agent_present ||
@@ -287,10 +291,14 @@ try {
.then(() => "failed"),
]);
if (installOutcome === "failed") {
throw new Error("LocalAgent installation failed before Runner registration.");
throw new Error(
"LocalAgent installation failed before Runner registration.",
);
}
if (await nextButton.isDisabled()) {
throw new Error("Create & Deploy remained disabled after LocalAgent installation.");
throw new Error(
"Create & Deploy remained disabled after LocalAgent installation.",
);
}
const [installedPluginsResponse, installedMetadataResponse] =
@@ -298,8 +306,7 @@ try {
apiJson(backendUrl, "/api/v1/plugins", { token }),
apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token }),
]);
const postInstallPlugins =
installedPluginsResponse.json.data?.plugins || [];
const postInstallPlugins = installedPluginsResponse.json.data?.plugins || [];
const installedRunnerStage = installedMetadataResponse.json.data?.configs
?.find((config) => config.name === "ai")
?.stages?.find((stage) => stage.name === "runner");
@@ -338,7 +345,7 @@ try {
}
result.status = "pass";
result.reason =
"A clean first-run instance discovered LocalAgent in the AgentRunner catalog, installed and registered it, selected it, and enabled Create & Deploy.";
"A clean first-run instance discovered LocalAgent in the Runner catalog, installed and registered it, selected it, and enabled Create & Deploy.";
} catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message;
@@ -42,26 +42,83 @@ const result = {
};
const repositories = [
{ id: "langbot", directory: "LangBot", envKey: "LANGBOT_REPO", manifest: false },
{ id: "plugin-sdk", directory: "langbot-plugin-sdk", envKey: "LANGBOT_PLUGIN_SDK_REPO", manifest: false },
{ id: "agent-runner", directory: "langbot-agent-runner", envKey: "LANGBOT_AGENT_RUNNER_REPO", manifest: false },
{ id: "local-agent", directory: "langbot-local-agent", envKey: "LANGBOT_LOCAL_AGENT_REPO", identity: "langbot-team/LocalAgent" },
{ id: "control-plane", directory: "langbot-agent-control-plane", envKey: "LANGBOT_AGENT_CONTROL_PLANE_REPO", identity: "langbot/agent-control-plane" },
{ id: "longterm-memory", directory: "langbot-longterm-memory", envKey: "LANGBOT_LONGTERM_MEMORY_REPO", identity: "langbot-team/LongTermMemory" },
{ id: "parser", directory: "langbot-parser", envKey: "LANGBOT_PARSER_PLUGIN_REPO", identity: "langbot-team/GeneralParsers" },
{ id: "rag", directory: "langbot-rag", envKey: "LANGBOT_RAG_PLUGIN_REPO", identity: "langbot-team/LangRAG" },
{ id: "skill-authoring", directory: "langbot-skill-authoring", envKey: "LANGBOT_SKILL_AUTHORING_REPO", identity: "huanghuoguoguo/skill-authoring" },
{
id: "langbot",
directory: "LangBot",
envKey: "LANGBOT_REPO",
manifest: false,
},
{
id: "plugin-sdk",
directory: "langbot-plugin-sdk",
envKey: "LANGBOT_PLUGIN_SDK_REPO",
manifest: false,
},
{
id: "agent-runner",
directory: "langbot-agent-runner",
envKey: "LANGBOT_RUNNER_REPO",
manifest: false,
},
{
id: "local-agent",
directory: "langbot-local-agent",
envKey: "LANGBOT_LOCAL_AGENT_REPO",
identity: "langbot-team/LocalAgent",
},
{
id: "control-plane",
directory: "langbot-agent-control-plane",
envKey: "LANGBOT_AGENT_CONTROL_PLANE_REPO",
identity: "langbot/agent-control-plane",
},
{
id: "longterm-memory",
directory: "langbot-longterm-memory",
envKey: "LANGBOT_LONGTERM_MEMORY_REPO",
identity: "langbot-team/LongTermMemory",
},
{
id: "parser",
directory: "langbot-parser",
envKey: "LANGBOT_PARSER_PLUGIN_REPO",
identity: "langbot-team/GeneralParsers",
},
{
id: "rag",
directory: "langbot-rag",
envKey: "LANGBOT_RAG_PLUGIN_REPO",
identity: "langbot-team/LangRAG",
},
{
id: "skill-authoring",
directory: "langbot-skill-authoring",
envKey: "LANGBOT_SKILL_AUTHORING_REPO",
identity: "huanghuoguoguo/skill-authoring",
},
];
function run(command, args, options = {}) {
return new Promise((resolvePromise) => {
const child = spawn(command, args, { ...options, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
const child = spawn(command, args, {
...options,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.on("error", (error) => resolvePromise({ status: null, stdout, stderr, error }));
child.on("close", (status) => resolvePromise({ status, stdout, stderr, error: null }));
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) =>
resolvePromise({ status: null, stdout, stderr, error }),
);
child.on("close", (status) =>
resolvePromise({ status, stdout, stderr, error: null }),
);
});
}
@@ -71,31 +128,50 @@ function addCheck(name, status, detail = {}) {
try {
const langbotRepo = await resolveLangBotRepo();
const workspaceRoot = resolve(env.LANGBOT_WORKSPACE_ROOT || dirname(langbotRepo));
const workspaceRoot = resolve(
env.LANGBOT_WORKSPACE_ROOT || dirname(langbotRepo),
);
const resolved = {};
for (const repository of repositories) {
const path = resolve(env[repository.envKey] || (repository.id === "langbot" ? langbotRepo : join(workspaceRoot, repository.directory)));
const path = resolve(
env[repository.envKey] ||
(repository.id === "langbot"
? langbotRepo
: join(workspaceRoot, repository.directory)),
);
resolved[repository.id] = path;
try {
await access(join(path, ".git"));
} catch {
addCheck(`repo:${repository.id}`, "fail", { path, reason: "Git checkout is missing." });
addCheck(`repo:${repository.id}`, "fail", {
path,
reason: "Git checkout is missing.",
});
continue;
}
const branch = await run("git", ["branch", "--show-current"], { cwd: path });
const branch = await run("git", ["branch", "--show-current"], {
cwd: path,
});
const branchName = branch.stdout.trim();
const compatible = branch.status === 0 && /^(?:main|dev\/4\.11\.x)$/.test(branchName);
const compatible =
branch.status === 0 && /^(?:main|dev\/4\.11\.x)$/.test(branchName);
addCheck(`repo:${repository.id}`, compatible ? "pass" : "fail", {
path,
branch: branchName,
reason: compatible ? "" : "Expected main or dev/4.11.x compatibility branch.",
reason: compatible
? ""
: "Expected main or dev/4.11.x compatibility branch.",
});
const dirty = await run("git", ["status", "--short"], { cwd: path });
if (dirty.stdout.trim()) {
result.warnings.push({ name: `dirty:${repository.id}`, path, entries: dirty.stdout.trim().split(/\r?\n/).length });
result.warnings.push({
name: `dirty:${repository.id}`,
path,
entries: dirty.stdout.trim().split(/\r?\n/).length,
});
}
if (repository.identity) {
@@ -105,13 +181,20 @@ try {
const author = manifest.match(/^\s{2}author:\s*([^\s#]+)/m)?.[1] || "";
const name = manifest.match(/^\s{2}name:\s*([^\s#]+)/m)?.[1] || "";
const identity = `${author}/${name}`;
addCheck(`manifest:${repository.id}`, identity === repository.identity ? "pass" : "fail", {
path: manifestPath,
identity,
expected_identity: repository.identity,
});
addCheck(
`manifest:${repository.id}`,
identity === repository.identity ? "pass" : "fail",
{
path: manifestPath,
identity,
expected_identity: repository.identity,
},
);
} catch (error) {
addCheck(`manifest:${repository.id}`, "fail", { path: manifestPath, reason: error.message });
addCheck(`manifest:${repository.id}`, "fail", {
path: manifestPath,
reason: error.message,
});
}
}
}
@@ -121,41 +204,69 @@ try {
await access(python);
addCheck("langbot-venv", "pass", { python });
} catch {
addCheck("langbot-venv", "fail", { python, reason: "LangBot virtualenv Python is missing." });
addCheck("langbot-venv", "fail", {
python,
reason: "LangBot virtualenv Python is missing.",
});
}
const sdkSrc = join(resolved["plugin-sdk"], "src");
const importProbe = await run(python, ["-c", [
"import json, pathlib, langbot_plugin",
"from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput",
"from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult",
"print(json.dumps({'path': str(pathlib.Path(langbot_plugin.__file__).resolve()), 'entities': [AgentInput.__name__, AgentRunResult.__name__]}))",
].join("; ")], {
cwd: resolved.langbot,
env: { ...env, PYTHONPATH: [sdkSrc, env.PYTHONPATH].filter(Boolean).join(delimiter) },
});
const importProbe = await run(
python,
[
"-c",
[
"import json, pathlib, langbot_plugin",
"from langbot_plugin.api.entities.builtin.runner.input import AgentInput",
"from langbot_plugin.api.entities.builtin.runner.result import RunnerResult",
"print(json.dumps({'path': str(pathlib.Path(langbot_plugin.__file__).resolve()), 'entities': [AgentInput.__name__, RunnerResult.__name__]}))",
].join("; "),
],
{
cwd: resolved.langbot,
env: {
...env,
PYTHONPATH: [sdkSrc, env.PYTHONPATH].filter(Boolean).join(delimiter),
},
},
);
let importDetail = {};
try { importDetail = JSON.parse(importProbe.stdout.trim()); } catch { importDetail = { stderr: importProbe.stderr.trim() }; }
const localSdkLoaded = importProbe.status === 0 && resolve(importDetail.path || "").startsWith(resolve(sdkSrc));
try {
importDetail = JSON.parse(importProbe.stdout.trim());
} catch {
importDetail = { stderr: importProbe.stderr.trim() };
}
const localSdkLoaded =
importProbe.status === 0 &&
resolve(importDetail.path || "").startsWith(resolve(sdkSrc));
addCheck("local-sdk-import", localSdkLoaded ? "pass" : "fail", {
expected_root: resolve(sdkSrc),
...importDetail,
reason: localSdkLoaded ? "" : "langbot_plugin did not load from the workspace SDK source tree.",
reason: localSdkLoaded
? ""
: "langbot_plugin did not load from the workspace SDK source tree.",
});
const failures = result.checks.filter((check) => check.status === "fail");
result.status = failures.length === 0 ? "pass" : "fail";
result.reason = failures.length === 0
? `Workspace compatibility preflight passed with ${result.warnings.length} non-blocking dirty-worktree warning(s).`
: `Workspace compatibility preflight found ${failures.length} blocking check(s).`;
result.reason =
failures.length === 0
? `Workspace compatibility preflight passed with ${result.warnings.length} non-blocking dirty-worktree warning(s).`
: `Workspace compatibility preflight found ${failures.length} blocking check(s).`;
} catch (error) {
result.status = /missing|ENOENT|not found/i.test(error.message) ? "env_issue" : "fail";
result.status = /missing|ENOENT|not found/i.test(error.message)
? "env_issue"
: "fail";
result.reason = error.message;
} finally {
const finishedAt = new Date();
result.finished_at = finishedAt.toISOString();
result.finished_at_local = localIsoWithOffset(finishedAt);
await writeFile(detailsPath, `${JSON.stringify({ checks: result.checks, warnings: result.warnings }, null, 2)}\n`, "utf8");
await writeFile(
detailsPath,
`${JSON.stringify({ checks: result.checks, warnings: result.warnings }, null, 2)}\n`,
"utf8",
);
await writeResult(paths, result);
console.log(JSON.stringify(result, null, 2));
}
+24 -24
View File
@@ -210,7 +210,7 @@
"case_summaries": [
{
"id": "acp-agent-runner-debug-chat",
"title": "ACP AgentRunner can answer through Debug Chat using real remote Claude",
"title": "ACP Runner can answer through Debug Chat using real remote Claude",
"mode": "agent-browser",
"area": "pipeline",
"type": "regression",
@@ -229,8 +229,8 @@
"node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env"
],
"setup_provides_env": [
"LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL",
"LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME"
"LANGBOT_ACP_RUNNER_PIPELINE_URL",
"LANGBOT_ACP_RUNNER_PIPELINE_NAME"
],
"evidence_required": [
"ui",
@@ -240,7 +240,7 @@
},
{
"id": "agent-run-ledger-audit",
"title": "Persisted AgentRunner run ledger passes end-to-end invariants",
"title": "Persisted Runner run ledger passes end-to-end invariants",
"mode": "probe",
"area": "agent",
"type": "regression",
@@ -264,7 +264,7 @@
},
{
"id": "agent-runner-async-db-readiness",
"title": "AgentRunner async DB readiness probe",
"title": "Runner async DB readiness probe",
"mode": "probe",
"area": "release",
"type": "smoke",
@@ -286,7 +286,7 @@
},
{
"id": "agent-runner-behavior-matrix",
"title": "AgentRunner deterministic behavior matrix probe",
"title": "Runner deterministic behavior matrix probe",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -308,7 +308,7 @@
},
{
"id": "agent-runner-fixture-contract",
"title": "QA AgentRunner fixture contract probe",
"title": "QA Runner fixture contract probe",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -355,7 +355,7 @@
},
{
"id": "agent-runner-ledger-concurrency",
"title": "AgentRunner run ledger concurrency and auth pytest probe",
"title": "Runner run ledger concurrency and auth pytest probe",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -378,7 +378,7 @@
},
{
"id": "agent-runner-ledger-contention",
"title": "AgentRunner ledger SQLite contention probe",
"title": "Runner ledger SQLite contention probe",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -401,7 +401,7 @@
},
{
"id": "agent-runner-ledger-invariants",
"title": "AgentRunner ledger schema and status invariants probe",
"title": "Runner ledger schema and status invariants probe",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -423,7 +423,7 @@
},
{
"id": "agent-runner-ledger-stress",
"title": "AgentRunner ledger lightweight stress probe",
"title": "Runner ledger lightweight stress probe",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -445,7 +445,7 @@
},
{
"id": "agent-runner-live-install",
"title": "QA AgentRunner package installs and registers in LangBot",
"title": "QA Runner package installs and registers in LangBot",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -468,7 +468,7 @@
},
{
"id": "agent-runner-qa-debug-chat",
"title": "QA AgentRunner returns deterministic output through Debug Chat",
"title": "QA Runner returns deterministic output through Debug Chat",
"mode": "agent-browser",
"area": "pipeline",
"type": "regression",
@@ -487,8 +487,8 @@
"node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env"
],
"setup_provides_env": [
"LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL",
"LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME"
"LANGBOT_QA_RUNNER_PIPELINE_URL",
"LANGBOT_QA_RUNNER_PIPELINE_NAME"
],
"evidence_required": [
"ui",
@@ -526,7 +526,7 @@
},
{
"id": "agent-runner-runtime-chaos",
"title": "AgentRunner SDK runtime chaos pytest probe",
"title": "Runner SDK runtime chaos pytest probe",
"mode": "probe",
"area": "release",
"type": "regression",
@@ -657,7 +657,7 @@
},
{
"id": "dify-agent-debug-chat",
"title": "Dify AgentRunner returns a response through Pipeline Debug Chat",
"title": "Dify Runner returns a response through Pipeline Debug Chat",
"mode": "agent-browser",
"area": "pipeline",
"type": "provider",
@@ -1937,7 +1937,7 @@
},
{
"id": "wizard-runner-marketplace-catalog",
"title": "Quick Start installs a published AgentRunner on a clean instance",
"title": "Quick Start installs a published Runner on a clean instance",
"mode": "agent-browser",
"area": "wizard",
"type": "feature",
@@ -2289,7 +2289,7 @@
{
"id": "langbot-workspace-release-gate",
"title": "LangBot workspace top-down release gate",
"description": "Broad release gate combining deterministic repository contracts with representative browser workflows, plugin pages, RAG/parser, EBA, external AgentRunner, and one complex LocalAgent task.",
"description": "Broad release gate combining deterministic repository contracts with representative browser workflows, plugin pages, RAG/parser, EBA, external Runner, and one complex LocalAgent task.",
"type": "release_gate",
"priority": "p0",
"tags": [
@@ -2352,7 +2352,7 @@
"fixtures": [
{
"id": "qa-agent-runner-behaviors",
"title": "Deterministic AgentRunner behavior matrix",
"title": "Deterministic Runner behavior matrix",
"kind": "json",
"path": "fixtures/agent-runner/qa-runner-behaviors.json",
"related_cases": [
@@ -2363,7 +2363,7 @@
},
{
"id": "qa-agent-runner-source",
"title": "QA deterministic AgentRunner fixture source",
"title": "QA deterministic Runner fixture source",
"kind": "plugin_source",
"path": "fixtures/plugins/qa-agent-runner/manifest.yaml",
"related_cases": [
@@ -2375,7 +2375,7 @@
},
{
"id": "qa-agent-runner-package",
"title": "QA deterministic AgentRunner prebuilt package",
"title": "QA deterministic Runner prebuilt package",
"kind": "plugin_package",
"path": "fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg",
"related_cases": [
@@ -2486,7 +2486,7 @@
"troubleshooting_summaries": [
{
"id": "agent-runner-actor-context-fields",
"title": "AgentRunner reads old actor.type and actor.id fields",
"title": "Runner reads old actor.type and actor.id fields",
"category": "product",
"related_cases": [
"dify-agent-debug-chat",
@@ -2504,7 +2504,7 @@
},
{
"id": "ambiguous-runner-default-label",
"title": "AgentRunner selector shows multiple Default or 默认 options",
"title": "Runner selector shows multiple Default or 默认 options",
"category": "product",
"related_cases": [
"dify-agent-debug-chat",
+7 -7
View File
@@ -37,10 +37,10 @@ LANGBOT_NO_PROXY=localhost,127.0.0.1,::1
# LANGBOT_PIPELINE_NAME=Generic QA Pipeline
# LANGBOT_LOCAL_AGENT_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id=<local-agent-pipeline-uuid>
# LANGBOT_LOCAL_AGENT_PIPELINE_NAME=Local Agent QA Pipeline
# LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id=<acp-agent-runner-pipeline-uuid>
# LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME=ACP AgentRunner QA Pipeline
# LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET=yhh@101.34.71.12
# LANGBOT_ACP_AGENT_RUNNER_SSH_PORT=22
# LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE=
# LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS=
# LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE=/home/yhh/langbot-e2e/acp-workspace
# LANGBOT_ACP_RUNNER_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id=<acp-agent-runner-pipeline-uuid>
# LANGBOT_ACP_RUNNER_PIPELINE_NAME=ACP Runner QA Pipeline
# LANGBOT_ACP_RUNNER_SSH_TARGET=yhh@101.34.71.12
# LANGBOT_ACP_RUNNER_SSH_PORT=22
# LANGBOT_ACP_RUNNER_SSH_IDENTITY_FILE=
# LANGBOT_ACP_RUNNER_SSH_EXTRA_OPTIONS=
# LANGBOT_ACP_RUNNER_REMOTE_WORKSPACE=/home/yhh/langbot-e2e/acp-workspace
+1 -1
View File
@@ -66,7 +66,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
| `list_bot_event_route_statuses` | Inspect bot event-route runtime status |
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent, Pipeline and Event processor types |
| `get_processor_metadata` | Discover installed EventProcessor components, schemas and supported event patterns. |
| `get_processor_metadata` | Discover installed event-capable Runner components, schemas and supported event patterns. |
| `list_processor_runs` / `get_processor_run_events` | Read one Event processor instance run history and logs; paginate with `before_id` / `after_sequence`. |
| `debug_agent` | Execute a synthetic Agent event (`processor_uuid`, `payload`); requires `runtime.operate`. Returns final text and up to 1000 execution events (thinking, text, tool arguments/results). Platform tools use Mock; other configured tools execute normally. Optional `payload.mock`: `errors`/`results` keyed by platform tool name, `unsupported_apis` lists unavailable platform APIs. |
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
+5 -5
View File
@@ -11,13 +11,13 @@ Use this skill when an agent needs to verify LangBot behavior through the WebUI
- **General WebUI testing**: read `references/web-ui-testing.md`.
- **Pipeline Debug Chat**: read `references/pipeline-debug-chat.md`.
- **Dify AgentRunner**: read `references/dify-agent-runner.md`.
- **Dify Runner**: read `references/dify-agent-runner.md`.
- **Model provider setup or test button**: read `references/model-provider-testing.md`.
- **Plugin install/runtime/tool/page smoke**: read `references/plugin-e2e-smoke.md`.
- **Local Agent Runner**: read `references/local-agent-runner.md`.
- **Local Agent Runner path coverage**: read `references/local-agent-runner-coverage.md`.
- **Diff-aware AgentRunner QA after code changes**: read `references/agent-runner-qa-workflow.md`.
- **Agent Runner release gate**: read `references/agent-runner-release-gate.md`.
- **Local Runner**: read `references/local-agent-runner.md`.
- **Local Runner path coverage**: read `references/local-agent-runner-coverage.md`.
- **Diff-aware Runner QA after code changes**: read `references/agent-runner-qa-workflow.md`.
- **Runner release gate**: read `references/agent-runner-release-gate.md`.
- **Sandbox-backed skill authoring**: read `references/sandbox-skill-authoring.md`.
- **LangRAG knowledge bases**: read `references/langrag-knowledge-base.md`.
- **MCP stdio tool testing**: read `references/mcp-stdio-testing.md`.
@@ -1,5 +1,5 @@
id: acp-agent-runner-debug-chat
title: "ACP AgentRunner can answer through Debug Chat using real remote Claude"
title: "ACP Runner can answer through Debug Chat using real remote Claude"
mode: agent-browser
area: pipeline
type: regression
@@ -19,42 +19,42 @@ env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
env_any:
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_ACP_RUNNER_PIPELINE_URL|LANGBOT_ACP_RUNNER_PIPELINE_NAME
automation: scripts/e2e/pipeline-debug-chat.mjs
automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_ACP_RUNNER_PIPELINE_URL
- LANGBOT_ACP_RUNNER_PIPELINE_NAME
- LANGBOT_E2E_PROMPT
- LANGBOT_E2E_EXPECTED_TEXT
- LANGBOT_E2E_EXPECTED_RUNNER_ID
- LANGBOT_E2E_RESPONSE_TIMEOUT_MS
automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot-team/ACPAgentRunner/default"
automation_prompt: "Do not launch any background agent, subagent, task, or worker. In this current ACP session, directly call the MCP tool named langbot_get_current_event exactly once and wait for its result. After it returns, reply exactly ACP_AGENT_RUNNER_E2E_OK with no other text."
automation_expected_text: "ACP_AGENT_RUNNER_E2E_OK"
automation_pipeline_url_env: LANGBOT_ACP_RUNNER_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_ACP_RUNNER_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot-team/ACPRunner/default"
automation_prompt: "Do not launch any background agent, subagent, task, or worker. In this current ACP session, directly call the MCP tool named langbot_get_current_event exactly once and wait for its result. After it returns, reply exactly ACP_RUNNER_E2E_OK with no other text."
automation_expected_text: "ACP_RUNNER_E2E_OK"
automation_response_timeout_ms: "300000"
setup_automation:
- "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env"
setup_provides_env:
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_ACP_RUNNER_PIPELINE_URL
- LANGBOT_ACP_RUNNER_PIPELINE_NAME
preconditions:
- "The remote machine has a working Claude Code login and can run npx -y @agentclientprotocol/claude-agent-acp."
- "LangBot can non-interactively SSH to the remote machine; the runner opens the MCP reverse tunnel automatically."
steps:
- "Open LANGBOT_FRONTEND_URL."
- "Open the ACP AgentRunner QA pipeline."
- "Confirm the pipeline AI runner is plugin:langbot-team/ACPAgentRunner/default."
- "Open the ACP Runner QA pipeline."
- "Confirm the pipeline AI runner is plugin:langbot-team/ACPRunner/default."
- "Open Debug Chat."
- "Ask the real remote Claude ACP agent to call langbot_get_current_event and return ACP_AGENT_RUNNER_E2E_OK exactly."
- "Ask the real remote Claude ACP agent to call langbot_get_current_event and return ACP_RUNNER_E2E_OK exactly."
checks:
- "UI: Debug Chat shows the user prompt."
- "UI: Debug Chat shows a Bot response containing ACP_AGENT_RUNNER_E2E_OK."
- "UI: Debug Chat shows a Bot response containing ACP_RUNNER_E2E_OK."
- "Logs: Backend logs include Processing request from person_websocket and Streaming completed for this run."
- "Logs: No acp runner request error appears for this run."
- "Console: No unexpected frontend errors appear during Debug Chat."
@@ -66,13 +66,13 @@ diagnostics:
- "Use scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env to create/update the pipeline."
- "For remote Claude on 101, verify ssh yhh@101.34.71.12 can run without password prompts; no separate ssh -R process is required."
success_patterns:
- "ACP_AGENT_RUNNER_E2E_OK"
- "ACP_RUNNER_E2E_OK"
- "Processing request from person_websocket"
- "Streaming completed"
failure_patterns:
- "acp.command_not_found"
- "acp.process_exited"
- "Agent runner plugin:langbot-team/ACPAgentRunner/default execution failed"
- "Agent runner plugin:langbot-team/ACPRunner/default execution failed"
troubleshooting:
- backend-not-listening
- plugin-runtime-timeout
@@ -1,5 +1,5 @@
id: agent-run-ledger-audit
title: "Persisted AgentRunner run ledger passes end-to-end invariants"
title: "Persisted Runner run ledger passes end-to-end invariants"
mode: probe
area: agent
type: regression
@@ -15,7 +15,7 @@ skills:
- langbot-testing
automation: scripts/e2e/agent-run-ledger-audit.mjs
steps:
- "Set LANGBOT_AGENT_RUN_ID to audit a specific run, or leave it unset to audit the latest persisted AgentRunner run."
- "Set LANGBOT_AGENT_RUN_ID to audit a specific run, or leave it unset to audit the latest persisted Runner run."
- "For an external runner's own CLI tools, set LANGBOT_AGENT_TOOL_AUTHORIZATION_MODE=runner-native; keep the default strict mode for Host tool calls."
- "Read the active LangBot database configuration and inspect the selected run and its ordered events."
- "Verify completed terminal state, run.completed, paired tool.call.started/completed events, stable tool names, and monotonic ordering."
@@ -1,5 +1,5 @@
id: agent-runner-async-db-readiness
title: "AgentRunner async DB readiness probe"
title: "Runner async DB readiness probe"
mode: probe
area: release
type: smoke
@@ -1,5 +1,5 @@
id: agent-runner-behavior-matrix
title: "AgentRunner deterministic behavior matrix probe"
title: "Runner deterministic behavior matrix probe"
mode: probe
area: release
type: regression
@@ -1,5 +1,5 @@
id: agent-runner-fixture-contract
title: "QA AgentRunner fixture contract probe"
title: "QA Runner fixture contract probe"
mode: probe
area: release
type: regression
@@ -17,19 +17,19 @@ env:
automation: skills/langbot-testing/probes/agent-runner-fixture-contract.mjs
steps:
- "Run `rtk bin/lbs test run agent-runner-fixture-contract --dry-run` first; remove `--dry-run` after checking the planned evidence directory."
- "Automation imports the QA AgentRunner fixture source and executes normal, streaming, and controlled-failure paths with SDK entities."
- "Automation imports the QA Runner fixture source and executes normal, streaming, and controlled-failure paths with SDK entities."
checks:
- "automation-result.json status is pass."
- "probe-stdout.log contains QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK."
- "Normal input returns QA_AGENT_RUNNER_OK:<input>."
- "probe-stdout.log contains QA_RUNNER_FIXTURE_CONTRACT_OK."
- "Normal input returns QA_RUNNER_OK:<input>."
- "Streaming input emits message.delta chunks and completes."
- "Failure input returns QA_AGENT_RUNNER_CONTROLLED_FAILURE."
- "Failure input returns QA_RUNNER_CONTROLLED_FAILURE."
evidence_required:
- filesystem
diagnostics:
- "This validates the deterministic fixture source contract. It does not prove the plugin package is installed in a live LangBot instance."
success_patterns:
- "QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK"
- "QA_RUNNER_FIXTURE_CONTRACT_OK"
failure_patterns:
- "AssertionError"
- "fixture contract exited"
@@ -25,7 +25,7 @@ automation_env:
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
preconditions:
- "The plugin runtime is enabled and connected, with at least one AgentRunner registered."
- "The plugin runtime is enabled and connected, with at least one Runner registered."
- "The target is a local test instance where a temporary Agent may be created and deleted."
steps:
- "Read the live plugin runtime status and select a registered runner from Agent metadata."
@@ -1,5 +1,5 @@
id: agent-runner-ledger-concurrency
title: "AgentRunner run ledger concurrency and auth pytest probe"
title: "Runner run ledger concurrency and auth pytest probe"
mode: probe
area: release
type: regression
@@ -1,5 +1,5 @@
id: agent-runner-ledger-contention
title: "AgentRunner ledger SQLite contention probe"
title: "Runner ledger SQLite contention probe"
mode: probe
area: release
type: regression
@@ -1,5 +1,5 @@
id: agent-runner-ledger-invariants
title: "AgentRunner ledger schema and status invariants probe"
title: "Runner ledger schema and status invariants probe"
mode: probe
area: release
type: regression
@@ -1,5 +1,5 @@
id: agent-runner-ledger-stress
title: "AgentRunner ledger lightweight stress probe"
title: "Runner ledger lightweight stress probe"
mode: probe
area: release
type: regression
@@ -1,5 +1,5 @@
id: agent-runner-live-install
title: "QA AgentRunner package installs and registers in LangBot"
title: "QA Runner package installs and registers in LangBot"
mode: probe
area: release
type: regression
@@ -25,7 +25,7 @@ automation_expected_tool: ""
automation_expected_runner_id: "plugin:qa/agent-runner/default"
steps:
- "Run `rtk bin/lbs test run agent-runner-live-install --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance."
- "Automation authenticates the local test user, uploads the QA AgentRunner .lbpkg package, waits for the install task, and reads pipeline metadata."
- "Automation authenticates the local test user, uploads the QA Runner .lbpkg package, waits for the install task, and reads pipeline metadata."
checks:
- "automation-result.json status is pass."
- "/api/v1/plugins lists qa/agent-runner after install."
@@ -1,5 +1,5 @@
id: agent-runner-qa-debug-chat
title: "QA AgentRunner returns deterministic output through Debug Chat"
title: "QA Runner returns deterministic output through Debug Chat"
mode: agent-browser
area: pipeline
type: regression
@@ -17,21 +17,21 @@ skills:
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL
- LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_QA_RUNNER_PIPELINE_URL
- LANGBOT_QA_RUNNER_PIPELINE_NAME
automation: scripts/e2e/pipeline-debug-chat.mjs
automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
- LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL
- LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_QA_RUNNER_PIPELINE_URL
- LANGBOT_QA_RUNNER_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_QA_RUNNER_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_QA_RUNNER_PIPELINE_NAME
automation_expected_runner_id: "plugin:qa/agent-runner/default"
automation_prompt: "hello-live"
automation_expected_text: "QA_AGENT_RUNNER_OK:hello-live"
automation_expected_text: "QA_RUNNER_OK:hello-live"
automation_response_timeout_ms: "120000"
automation_debug_chat_response_p95_ms: "120000"
automation_reset_debug_chat: "1"
@@ -39,17 +39,17 @@ setup_automation:
- "case:agent-runner-live-install"
- "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env"
setup_provides_env:
- LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL
- LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_QA_RUNNER_PIPELINE_URL
- LANGBOT_QA_RUNNER_PIPELINE_NAME
steps:
- "Open LANGBOT_FRONTEND_URL."
- "Open the pipeline from LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL or LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME."
- "Open the pipeline from LANGBOT_QA_RUNNER_PIPELINE_URL or LANGBOT_QA_RUNNER_PIPELINE_NAME."
- "Confirm the pipeline AI runner is plugin:qa/agent-runner/default."
- "Open Debug Chat."
- "Send: hello-live."
checks:
- "UI: The user message appears in Debug Chat."
- "UI: A Bot message appears and contains QA_AGENT_RUNNER_OK:hello-live."
- "UI: A Bot message appears and contains QA_RUNNER_OK:hello-live."
- "API diagnostic: pipeline config uses plugin:qa/agent-runner/default."
- "Console: No unexpected frontend runtime errors appear during the send/receive path."
evidence_required:
@@ -62,7 +62,7 @@ diagnostics:
- "This is the deterministic live execution proof that sits after fixture contract and live install."
- "If the runner id mismatch is reported, rerun ensure-qa-agent-runner-pipeline.mjs --write-env."
success_patterns:
- "QA_AGENT_RUNNER_OK:hello-live"
- "QA_RUNNER_OK:hello-live"
failure_patterns:
- "plugin:qa/agent-runner/default execution failed"
- "Action invoke_llm_stream call timed out"
@@ -19,7 +19,7 @@ env:
- LANGBOT_BACKEND_URL
env_any:
- LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_ACP_RUNNER_PIPELINE_URL|LANGBOT_ACP_RUNNER_PIPELINE_NAME
automation: scripts/e2e/agent-runner-release-preflight.mjs
automation_env:
- LANGBOT_FRONTEND_URL
@@ -28,24 +28,24 @@ automation_env:
- LANGBOT_CHROMIUM_EXECUTABLE
automation_env_any:
- LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_ACP_RUNNER_PIPELINE_URL|LANGBOT_ACP_RUNNER_PIPELINE_NAME
preconditions:
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent release pipeline."
- "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL or LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME points to the ACP AgentRunner release pipeline."
- "LANGBOT_ACP_RUNNER_PIPELINE_URL or LANGBOT_ACP_RUNNER_PIPELINE_NAME points to the ACP Runner release pipeline."
- "The active browser profile is authenticated for the same LangBot backend."
- "By default the preflight performs a cheap model test for the local-agent primary model; set LANGBOT_PREFLIGHT_TEST_MODELS=0 only when deliberately classifying model credentials outside this run."
steps:
- "Open LANGBOT_FRONTEND_URL with the configured browser profile."
- "Use the browser token to call LangBot backend readiness APIs without printing token values."
- "Check plugin runtime status, Box status, required runner plugins, qa-plugin-smoke, and qa_plugin_echo."
- "Resolve the local-agent and ACP AgentRunner QA pipelines from their case-specific env vars."
- "Resolve the local-agent and ACP Runner QA pipelines from their case-specific env vars."
- "Assert each pipeline uses the expected runner id."
- "Assert the external runner pipeline uses the expected runner id."
- "Assert the local-agent primary model advertises func_call and vision for the full release gate."
- "Run the local-agent primary model test endpoint unless LANGBOT_PREFLIGHT_TEST_MODELS=0."
checks:
- "API diagnostic: api-diagnostic.json has no blockers and no env_issues."
- "API diagnostic: required pipelines resolve to plugin:langbot-team/LocalAgent/default and plugin:langbot-team/ACPAgentRunner/default."
- "API diagnostic: required pipelines resolve to plugin:langbot-team/LocalAgent/default and plugin:langbot-team/ACPRunner/default."
- "API diagnostic: qa_plugin_echo is exposed by /api/v1/tools."
- "API diagnostic: local-agent model check catches invalid credentials or missing func_call/vision before release E2E starts."
- "Secret safety: token values, api keys, and provider secrets are not printed."
@@ -1,5 +1,5 @@
id: agent-runner-runtime-chaos
title: "AgentRunner SDK runtime chaos pytest probe"
title: "Runner SDK runtime chaos pytest probe"
mode: probe
area: release
type: regression
@@ -19,10 +19,10 @@ automation: skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs
steps:
- "Run `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run` first; remove `--dry-run` after checking the SDK repo target and evidence directory."
- "Automation resolves LANGBOT_PLUGIN_SDK_REPO, defaulting to ../../langbot-plugin-sdk when the env var is unset."
- "Automation runs the existing SDK pytest files tests/runtime/plugin/test_mgr_agent_runner.py and tests/runtime/test_pull_api_handlers.py."
- "Automation runs the existing SDK pytest files tests/runtime/plugin/test_mgr_runner.py and tests/runtime/test_pull_api_handlers.py."
checks:
- "automation-result.json status is pass."
- "pytest exit status is 0 for the existing AgentRunner runtime and pull API handler tests."
- "pytest exit status is 0 for the existing Runner runtime and pull API handler tests."
- "pytest-stdout.log and pytest-stderr.log are written under LBS_EVIDENCE_DIR."
evidence_required:
- filesystem
@@ -45,7 +45,7 @@ steps:
- "Select exactly one Box child whose parent is main.py running from LANGBOT_REPO; abort on zero or multiple matches."
- "Send SIGTERM to that Box child and poll process, Box status, MCP runtime info, and global tools for up to 30 seconds."
- "Without running MCP setup or registration, reset Debug Chat and call qa_mcp_echo with a unique per-run value through the browser."
- "Audit the matching AgentRunner ledger run and require the exact qa_mcp_echo arguments plus the complete tool result text."
- "Audit the matching Runner ledger run and require the exact qa_mcp_echo arguments plus the complete tool result text."
checks:
- "The old Box PID exits and a new Box PID appears under the same LangBot parent."
- "Box returns available=true with at least one active session and managed process."
@@ -1,5 +1,5 @@
id: dify-agent-debug-chat
title: "Dify AgentRunner returns a response through Pipeline Debug Chat"
title: "Dify Runner returns a response through Pipeline Debug Chat"
mode: agent-browser
area: pipeline
type: provider
@@ -18,8 +18,8 @@ skills:
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_ACP_RUNNER_PIPELINE_URL
- LANGBOT_ACP_RUNNER_PIPELINE_NAME
preconditions:
- "An external-harness runner pipeline (e.g. ACP remote claude-code) is configured with langbot-assets-enabled=true so the LangBot MCP gateway is exposed to the harness."
- "The remote harness (claude-code) is reachable and responsive (claude -p returns within the runner timeout)."
@@ -29,10 +29,10 @@ automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
- LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
- LANGBOT_ACP_RUNNER_PIPELINE_URL
- LANGBOT_ACP_RUNNER_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_ACP_RUNNER_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_ACP_RUNNER_PIPELINE_NAME
automation_prompt: "You have LangBot tools available via an MCP server (tools prefixed langbot_). Call langbot_list_assets with asset_types = [\"skills\",\"tools\"]. Then reply with one single line: the literal token PROBEDONE, a space, the number of skills you found, a space, and the number of tools you found."
automation_expected_text: "PROBEDONE"
automation_response_timeout_ms: "540000"
@@ -1,5 +1,5 @@
id: wizard-runner-marketplace-catalog
title: "Quick Start installs a published AgentRunner on a clean instance"
title: "Quick Start installs a published Runner on a clean instance"
mode: agent-browser
area: wizard
type: feature
@@ -27,19 +27,19 @@ preconditions:
steps:
- "Start an isolated first-run instance and confirm zero installed plugins and zero registered runners."
- "Resume Quick Start at the AI Engine step with a temporary disabled Bot."
- "Confirm the browser requests Marketplace plugins with component_filter=AgentRunner."
- "Confirm the browser requests Marketplace plugins with component_filter=Runner."
- "Confirm langbot-team/LocalAgent is published with an installable version and the Runner Extensions link is correct."
- "Install LocalAgent and wait for plugin initialization and AgentRunner registration."
- "Install LocalAgent and wait for plugin initialization and Runner registration."
- "Confirm Create & Deploy is disabled before installation and enabled after LocalAgent is selected."
- "Verify the layout at desktop and mobile widths."
checks:
- "API: The instance has zero installed plugins and zero registered runners."
- "API: The instance wizard status is none."
- "Network: Marketplace search uses component_filter=AgentRunner and type_filter=plugin."
- "Network: Marketplace search uses component_filter=Runner and type_filter=plugin."
- "UI: The AI Engine step displays the published langbot-team/LocalAgent card."
- "Marketplace: LocalAgent includes latest_version so installation can proceed."
- "Runtime: LocalAgent installs and registers plugin:langbot-team/LocalAgent/default."
- "UI: Browse Runner Extensions links to the AgentRunner-filtered market."
- "UI: Browse Runner Extensions links to the Runner-filtered market."
- "UI: Create & Deploy transitions from disabled to enabled only after Runner selection."
- "Console: No unexpected frontend errors appear during the flow."
- "Cleanup: Wizard progress and the temporary Bot are removed."
@@ -18,7 +18,7 @@ steps:
- "Discover the active LangBot checkout and sibling workspace repositories, with LANGBOT_WORKSPACE_ROOT and repository-specific env overrides available for non-default layouts."
- "Verify every checkout is on main or dev/4.11.x and record dirty worktrees as warnings only."
- "Validate plugin manifest identities for LocalAgent, Control Plane, LongTermMemory, GeneralParsers, LangRAG, and Skill Authoring."
- "Use the LangBot virtualenv to import langbot_plugin and AgentRunner Protocol entities from the workspace SDK source tree."
- "Use the LangBot virtualenv to import langbot_plugin and Runner Protocol entities from the workspace SDK source tree."
checks:
- "workspace-preflight.json contains no failed checks."
- "The langbot_plugin import path is inside the discovered langbot-plugin-sdk/src directory."
@@ -16,7 +16,7 @@ skills:
automation: scripts/e2e/workspace-repository-contracts.mjs
steps:
- "Resolve the active LangBot virtualenv and workspace SDK source path."
- "Run tests independently for AgentRunner, Control Plane, LongTermMemory, Parser, RAG, Skill Authoring, LocalAgent, LangBot Agent/Provider, the skills CLI, and SDK runtime contracts."
- "Run tests independently for Runner, Control Plane, LongTermMemory, Parser, RAG, Skill Authoring, LocalAgent, LangBot Agent/Provider, the skills CLI, and SDK runtime contracts."
- "Run SDK packaging blackbox separately so an isolated build dependency network failure is classified as env_issue without masking product test failures."
- "Write per-repository stdout, stderr, status, and duration under repository-contracts/."
checks:
@@ -1,7 +1,7 @@
[
{
"id": "qa-agent-runner-behaviors",
"title": "Deterministic AgentRunner behavior matrix",
"title": "Deterministic Runner behavior matrix",
"kind": "json",
"path": "fixtures/agent-runner/qa-runner-behaviors.json",
"related_cases": [
@@ -13,7 +13,7 @@
},
{
"id": "qa-agent-runner-source",
"title": "QA deterministic AgentRunner fixture source",
"title": "QA deterministic Runner fixture source",
"kind": "plugin_source",
"path": "fixtures/plugins/qa-agent-runner/manifest.yaml",
"related_cases": [
@@ -22,11 +22,11 @@
"agent-runner-live-install",
"agent-runner-qa-debug-chat"
],
"checks": ["exists", "qa_agent_runner_source"]
"checks": ["exists", "qa_runner_source"]
},
{
"id": "qa-agent-runner-package",
"title": "QA deterministic AgentRunner prebuilt package",
"title": "QA deterministic Runner prebuilt package",
"kind": "plugin_package",
"path": "fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg",
"related_cases": [
@@ -1,6 +1,6 @@
# QA AgentRunner Fixture
# QA Runner Fixture
Deterministic AgentRunner plugin source used by `langbot-skills` probes and future browser release-gate cases.
Deterministic Runner plugin source used by `langbot-skills` probes and future browser release-gate cases.
Runner id after installation should be:
@@ -10,6 +10,6 @@ plugin:qa/agent-runner/default
Expected behavior:
- normal input returns `QA_AGENT_RUNNER_OK:<input>`
- normal input returns `QA_RUNNER_OK:<input>`
- input containing `stream` emits streaming chunks then completes
- input containing `fail` returns `QA_AGENT_RUNNER_CONTROLLED_FAILURE`
- input containing `fail` returns `QA_RUNNER_CONTROLLED_FAILURE`
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="QA AgentRunner icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="QA Runner icon">
<rect width="64" height="64" rx="12" fill="#111827"/>
<path d="M16 20h32v22H35l-7 8v-8H16z" fill="#22c55e"/>
<path d="M24 30h16" stroke="#111827" stroke-width="4" stroke-linecap="round"/>

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 301 B

@@ -1,39 +0,0 @@
from __future__ import annotations
import typing
from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner
from langbot_plugin.api.entities.builtin.agent_runner import AgentRunContext, AgentRunResult
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
class DefaultAgentRunner(AgentRunner):
async def run(
self,
ctx: AgentRunContext,
) -> typing.AsyncGenerator[AgentRunResult, None]:
text = (ctx.input.to_text() or "").strip()
if "fail" in text.lower():
yield AgentRunResult.run_failed(
ctx.run_id,
error="QA_AGENT_RUNNER_CONTROLLED_FAILURE",
code="qa.controlled_failure",
retryable=False,
)
return
content = f"QA_AGENT_RUNNER_OK:{text or 'empty'}"
if "stream" in text.lower():
for chunk in ("QA_", "AGENT_", f"RUNNER_OK:{text}"):
yield AgentRunResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=chunk),
)
yield AgentRunResult.run_completed(ctx.run_id, finish_reason="stop")
return
yield AgentRunResult.run_completed(
ctx.run_id,
Message(role="assistant", content=content),
finish_reason="stop",
)
@@ -0,0 +1,39 @@
from __future__ import annotations
import typing
from langbot_plugin.api.definition.components.runner.runner import Runner
from langbot_plugin.api.entities.builtin.runner import RunnerContext, RunnerResult
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
class DefaultRunner(Runner):
async def run(
self,
ctx: RunnerContext,
) -> typing.AsyncGenerator[RunnerResult, None]:
text = (ctx.input.to_text() or '').strip()
if 'fail' in text.lower():
yield RunnerResult.run_failed(
ctx.run_id,
error='QA_RUNNER_CONTROLLED_FAILURE',
code='qa.controlled_failure',
retryable=False,
)
return
content = f'QA_RUNNER_OK:{text or "empty"}'
if 'stream' in text.lower():
for chunk in ('QA_', 'AGENT_', f'RUNNER_OK:{text}'):
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role='assistant', content=chunk),
)
yield RunnerResult.run_completed(ctx.run_id, finish_reason='stop')
return
yield RunnerResult.run_completed(
ctx.run_id,
Message(role='assistant', content=content),
finish_reason='stop',
)
@@ -1,5 +1,5 @@
apiVersion: langbot/v1
kind: AgentRunner
kind: Runner
metadata:
name: default
label:
@@ -27,4 +27,4 @@ spec:
execution:
python:
path: default.py
attr: DefaultAgentRunner
attr: DefaultRunner
@@ -3,6 +3,6 @@ from __future__ import annotations
from langbot_plugin.api.definition.plugin import BasePlugin
class QAAgentRunnerPlugin(BasePlugin):
class QARunnerPlugin(BasePlugin):
async def initialize(self) -> None:
self.ready_marker = "qa-agent-runner-ready"
self.ready_marker = 'qa-agent-runner-ready'
@@ -6,20 +6,20 @@ metadata:
repository: https://example.invalid/langbot/qa-agent-runner
version: 0.1.0
description:
en_US: Deterministic AgentRunner fixture for LangBot QA.
zh_Hans: LangBot QA 使用的确定性 AgentRunner 夹具。
en_US: Deterministic Runner fixture for LangBot QA.
zh_Hans: LangBot QA 使用的确定性 Runner 夹具。
label:
en_US: QA AgentRunner
zh_Hans: QA AgentRunner
en_US: QA Runner
zh_Hans: QA Runner
icon: assets/icon.svg
spec:
config: []
components:
AgentRunner:
Runner:
fromDirs:
- path: components/agent_runner/
- path: components/runner/
maxDepth: 1
execution:
python:
path: main.py
attr: QAAgentRunnerPlugin
attr: QARunnerPlugin
@@ -7,7 +7,11 @@ import { delimiter, join, resolve } from "node:path";
import { env } from "node:process";
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
return date
.toISOString()
.replace(/\.\d{3}Z$/, "Z")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-|-$/g, "");
}
function localIsoWithOffset(date = new Date()) {
@@ -51,7 +55,14 @@ function run(command, timeoutMs, childEnv) {
});
child.on("error", (error) => {
clearTimeout(timeout);
resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null });
resolveDone({
stdout,
stderr,
error,
timedOut,
status: null,
signal: null,
});
});
child.on("close", (status, signal) => {
clearTimeout(timeout);
@@ -66,7 +77,7 @@ import json
import sys
from pathlib import Path
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
@@ -80,10 +91,10 @@ class App:
logger = Logger()
def descriptor():
return AgentRunnerDescriptor(
return RunnerDescriptor(
id='plugin:qa/agent-runner/default',
source='plugin',
label={'en_US': 'QA AgentRunner'},
label={'en_US': 'QA Runner'},
plugin_author='qa',
plugin_name='agent-runner',
runner_name='default',
@@ -139,18 +150,26 @@ async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "agent-runner-behavior-matrix";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
const evidenceDir = resolve(
env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId),
);
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const langbotRepo = resolve(root, env.LANGBOT_REPO || "..");
const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk");
const sdkRepo = resolve(
root,
env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk",
);
const sdkSrc = resolve(sdkRepo, "src");
const fixturePath = resolve(root, "skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json");
const fixturePath = resolve(
root,
"skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json",
);
const stdoutLog = join(evidenceDir, "probe-stdout.log");
const stderrLog = join(evidenceDir, "probe-stderr.log");
const automationResultJson = join(evidenceDir, "automation-result.json");
const resultJson = join(evidenceDir, "result.json");
const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const timeoutMs = Number(env.LANGBOT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const command = {
executable: "rtk",
args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script, fixturePath],
@@ -175,7 +194,12 @@ async function main() {
timeout_ms: timeoutMs,
exit_status: null,
signal: null,
evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson },
evidence: {
stdout_log: stdoutLog,
stderr_log: stderrLog,
automation_result_json: automationResultJson,
result_json: resultJson,
},
evidence_collected: ["filesystem"],
};
try {
@@ -185,7 +209,9 @@ async function main() {
} else {
const proc = await run(command, timeoutMs, {
...process.env,
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH]
.filter(Boolean)
.join(delimiter),
UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"),
});
await writeFile(stdoutLog, proc.stdout, "utf8");
@@ -198,7 +224,10 @@ async function main() {
} else if (proc.timedOut) {
result.status = "fail";
result.reason = `behavior matrix timed out after ${timeoutMs}ms`;
} else if (proc.status === 0 && proc.stdout.includes("QA_RUNNER_BEHAVIOR_MATRIX_OK")) {
} else if (
proc.status === 0 &&
proc.stdout.includes("QA_RUNNER_BEHAVIOR_MATRIX_OK")
) {
result.status = "pass";
result.reason = "behavior matrix passed";
} else {
@@ -219,7 +248,9 @@ async function main() {
await writeFile(resultJson, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
}
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
process.exit(
result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1,
);
}
await main();
@@ -7,7 +7,11 @@ import { delimiter, join, resolve } from "node:path";
import { env } from "node:process";
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
return date
.toISOString()
.replace(/\.\d{3}Z$/, "Z")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-|-$/g, "");
}
function localIsoWithOffset(date = new Date()) {
@@ -51,7 +55,14 @@ function run(command, timeoutMs, childEnv) {
});
child.on("error", (error) => {
clearTimeout(timeout);
resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null });
resolveDone({
stdout,
stderr,
error,
timedOut,
status: null,
signal: null,
});
});
child.on("close", (status, signal) => {
clearTimeout(timeout);
@@ -66,27 +77,27 @@ import importlib.util
import sys
from pathlib import Path
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import AgentEventContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.runner.trigger import AgentTrigger
fixture = Path(sys.argv[1])
runner_py = fixture / "components" / "agent_runner" / "default.py"
runner_py = fixture / "components" / "runner" / "default.py"
manifest = fixture / "manifest.yaml"
runner_yaml = fixture / "components" / "agent_runner" / "default.yaml"
runner_yaml = fixture / "components" / "runner" / "default.yaml"
assert manifest.exists(), manifest
assert runner_yaml.exists(), runner_yaml
spec = importlib.util.spec_from_file_location("qa_agent_runner_fixture", runner_py)
spec = importlib.util.spec_from_file_location("qa_runner_fixture", runner_py)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
def context(run_id, text):
return AgentRunContext(
return RunnerContext(
run_id=run_id,
trigger=AgentTrigger(type="message.received", source="webui"),
event=AgentEventContext(event_id=f"evt-{run_id}", event_type="message.received", source="webui"),
@@ -97,7 +108,7 @@ def context(run_id, text):
)
async def collect(text):
runner = module.DefaultAgentRunner()
runner = module.DefaultRunner()
results = []
async for result in runner.run(context(f"run-{len(text)}", text)):
results.append(result)
@@ -107,17 +118,17 @@ async def main():
normal = await collect("hello")
assert len(normal) == 1, normal
assert normal[0].type.value == "run.completed"
assert normal[0].data["message"]["content"] == "QA_AGENT_RUNNER_OK:hello"
assert normal[0].data["message"]["content"] == "QA_RUNNER_OK:hello"
stream = await collect("stream hello")
assert [item.type.value for item in stream] == ["message.delta", "message.delta", "message.delta", "run.completed"]
assert "".join(item.data["chunk"]["content"] for item in stream[:3]) == "QA_AGENT_RUNNER_OK:stream hello"
assert "".join(item.data["chunk"]["content"] for item in stream[:3]) == "QA_RUNNER_OK:stream hello"
failed = await collect("please fail")
assert len(failed) == 1
assert failed[0].type.value == "run.failed"
assert failed[0].data["error"] == "QA_AGENT_RUNNER_CONTROLLED_FAILURE"
print("QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK")
assert failed[0].data["error"] == "QA_RUNNER_CONTROLLED_FAILURE"
print("QA_RUNNER_FIXTURE_CONTRACT_OK")
asyncio.run(main())
`;
@@ -126,18 +137,30 @@ async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "agent-runner-fixture-contract";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
const evidenceDir = resolve(
env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId),
);
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk");
const sdkRepo = resolve(
root,
env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk",
);
const sdkSrc = resolve(sdkRepo, "src");
const fixturePath = resolve(root, "skills/langbot-testing/fixtures/plugins/qa-agent-runner");
const fixturePath = resolve(
root,
"skills/langbot-testing/fixtures/plugins/qa-agent-runner",
);
const stdoutLog = join(evidenceDir, "probe-stdout.log");
const stderrLog = join(evidenceDir, "probe-stderr.log");
const automationResultJson = join(evidenceDir, "automation-result.json");
const resultJson = join(evidenceDir, "result.json");
const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const command = { executable: "rtk", args: ["uv", "run", "--no-sync", "python", "-c", script, fixturePath], cwd: sdkRepo };
const timeoutMs = Number(env.LANGBOT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const command = {
executable: "rtk",
args: ["uv", "run", "--no-sync", "python", "-c", script, fixturePath],
cwd: sdkRepo,
};
const result = {
source: "automation",
probe: "agent-runner-fixture-contract",
@@ -156,7 +179,12 @@ async function main() {
timeout_ms: timeoutMs,
exit_status: null,
signal: null,
evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson },
evidence: {
stdout_log: stdoutLog,
stderr_log: stderrLog,
automation_result_json: automationResultJson,
result_json: resultJson,
},
evidence_collected: ["filesystem"],
};
try {
@@ -166,7 +194,9 @@ async function main() {
} else {
const proc = await run(command, timeoutMs, {
...process.env,
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH]
.filter(Boolean)
.join(delimiter),
UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"),
});
await writeFile(stdoutLog, proc.stdout, "utf8");
@@ -179,9 +209,12 @@ async function main() {
} else if (proc.timedOut) {
result.status = "fail";
result.reason = `fixture contract probe timed out after ${timeoutMs}ms`;
} else if (proc.status === 0 && proc.stdout.includes("QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK")) {
} else if (
proc.status === 0 &&
proc.stdout.includes("QA_RUNNER_FIXTURE_CONTRACT_OK")
) {
result.status = "pass";
result.reason = "QA AgentRunner fixture contract passed";
result.reason = "QA Runner fixture contract passed";
} else {
result.status = "fail";
result.reason = `fixture contract exited with status ${proc.status}`;
@@ -200,7 +233,9 @@ async function main() {
await writeFile(resultJson, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
}
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
process.exit(
result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1,
);
}
await main();
@@ -8,7 +8,8 @@ await runPytestProbe({
defaultRepo: "..",
pythonPathEnvKeys: ["LANGBOT_PLUGIN_SDK_REPO"],
defaultPythonPaths: ["../../langbot-plugin-sdk/src"],
description: "LangBot AgentRunner run ledger claim, lease, authorization, and runtime-admin pytest probe.",
description:
"LangBot Runner run ledger claim, lease, authorization, and runtime-admin pytest probe.",
testTargets: [
"tests/unit_tests/agent/test_run_ledger_store.py::test_create_queued_run_claim_renew_release",
"tests/unit_tests/agent/test_run_ledger_store.py::test_expired_claim_can_be_reclaimed",
@@ -7,7 +7,11 @@ import { delimiter, join, resolve } from "node:path";
import { env } from "node:process";
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
return date
.toISOString()
.replace(/\.\d{3}Z$/, "Z")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-|-$/g, "");
}
function localIsoWithOffset(date = new Date()) {
@@ -51,7 +55,14 @@ function run(command, timeoutMs, childEnv) {
});
child.on("error", (error) => {
clearTimeout(timeout);
resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null });
resolveDone({
stdout,
stderr,
error,
timedOut,
status: null,
signal: null,
});
});
child.on("close", (status, signal) => {
clearTimeout(timeout);
@@ -149,18 +160,23 @@ async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "agent-runner-ledger-contention";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
const evidenceDir = resolve(
env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId),
);
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const langbotRepo = resolve(root, env.LANGBOT_REPO || "..");
const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk");
const sdkRepo = resolve(
root,
env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk",
);
const sdkSrc = resolve(sdkRepo, "src");
const dbPath = join(evidenceDir, "ledger-contention.sqlite3");
const stdoutLog = join(evidenceDir, "probe-stdout.log");
const stderrLog = join(evidenceDir, "probe-stderr.log");
const automationResultJson = join(evidenceDir, "automation-result.json");
const resultJson = join(evidenceDir, "result.json");
const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const timeoutMs = Number(env.LANGBOT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const command = {
executable: "rtk",
args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script, dbPath],
@@ -185,7 +201,13 @@ async function main() {
timeout_ms: timeoutMs,
exit_status: null,
signal: null,
evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, database: dbPath, automation_result_json: automationResultJson, result_json: resultJson },
evidence: {
stdout_log: stdoutLog,
stderr_log: stderrLog,
database: dbPath,
automation_result_json: automationResultJson,
result_json: resultJson,
},
evidence_collected: ["filesystem"],
};
try {
@@ -195,7 +217,9 @@ async function main() {
} else {
const proc = await run(command, timeoutMs, {
...process.env,
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH]
.filter(Boolean)
.join(delimiter),
UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"),
});
await writeFile(stdoutLog, proc.stdout, "utf8");
@@ -208,7 +232,10 @@ async function main() {
} else if (proc.timedOut) {
result.status = "fail";
result.reason = `ledger contention timed out after ${timeoutMs}ms`;
} else if (proc.status === 0 && proc.stdout.includes("LEDGER_CONTENTION_OK")) {
} else if (
proc.status === 0 &&
proc.stdout.includes("LEDGER_CONTENTION_OK")
) {
result.status = "pass";
result.reason = "ledger contention probe passed";
} else {
@@ -229,7 +256,9 @@ async function main() {
await writeFile(resultJson, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
}
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
process.exit(
result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1,
);
}
await main();
@@ -7,7 +7,11 @@ import { delimiter, join, resolve } from "node:path";
import { env } from "node:process";
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
return date
.toISOString()
.replace(/\.\d{3}Z$/, "Z")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-|-$/g, "");
}
function localIsoWithOffset(date = new Date()) {
@@ -55,7 +59,14 @@ function runProcess(command, timeoutMs, childEnv) {
});
child.on("error", (error) => {
clearTimeout(timeout);
resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null });
resolveDone({
stdout,
stderr,
error,
timedOut,
status: null,
signal: null,
});
});
child.on("close", (status, signal) => {
clearTimeout(timeout);
@@ -124,11 +135,16 @@ async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "agent-runner-ledger-invariants";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
const evidenceDir = resolve(
env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId),
);
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const langbotRepo = resolveFromRoot(root, env.LANGBOT_REPO || "..");
const sdkRepo = resolveFromRoot(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk");
const sdkRepo = resolveFromRoot(
root,
env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk",
);
const sdkSrc = resolve(sdkRepo, "src");
const stdoutLog = join(evidenceDir, "probe-stdout.log");
const stderrLog = join(evidenceDir, "probe-stderr.log");
@@ -139,7 +155,7 @@ async function main() {
args: [resolve(langbotRepo, ".venv/bin/python"), "-c", probeScript],
cwd: langbotRepo,
};
const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const timeoutMs = Number(env.LANGBOT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const result = {
source: "automation",
probe: "python-sync",
@@ -174,7 +190,9 @@ async function main() {
} else {
const childEnv = {
...process.env,
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH]
.filter(Boolean)
.join(delimiter),
UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"),
};
await mkdir(childEnv.UV_CACHE_DIR, { recursive: true });
@@ -210,7 +228,9 @@ async function main() {
await writeFile(resultJson, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
}
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
process.exit(
result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1,
);
}
await main();
@@ -7,7 +7,11 @@ import { delimiter, join, resolve } from "node:path";
import { env } from "node:process";
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
return date
.toISOString()
.replace(/\.\d{3}Z$/, "Z")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-|-$/g, "");
}
function localIsoWithOffset(date = new Date()) {
@@ -51,7 +55,14 @@ function run(command, timeoutMs, childEnv) {
});
child.on("error", (error) => {
clearTimeout(timeout);
resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null });
resolveDone({
stdout,
stderr,
error,
timedOut,
status: null,
signal: null,
});
});
child.on("close", (status, signal) => {
clearTimeout(timeout);
@@ -116,17 +127,22 @@ async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "agent-runner-ledger-stress";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
const evidenceDir = resolve(
env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId),
);
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const langbotRepo = resolve(root, env.LANGBOT_REPO || "..");
const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk");
const sdkRepo = resolve(
root,
env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk",
);
const sdkSrc = resolve(sdkRepo, "src");
const stdoutLog = join(evidenceDir, "probe-stdout.log");
const stderrLog = join(evidenceDir, "probe-stderr.log");
const automationResultJson = join(evidenceDir, "automation-result.json");
const resultJson = join(evidenceDir, "result.json");
const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const timeoutMs = Number(env.LANGBOT_RUNNER_PROBE_TIMEOUT_MS || "30000");
const command = {
executable: "rtk",
args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script],
@@ -150,7 +166,12 @@ async function main() {
timeout_ms: timeoutMs,
exit_status: null,
signal: null,
evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson },
evidence: {
stdout_log: stdoutLog,
stderr_log: stderrLog,
automation_result_json: automationResultJson,
result_json: resultJson,
},
evidence_collected: ["filesystem"],
};
try {
@@ -160,7 +181,9 @@ async function main() {
} else {
const proc = await run(command, timeoutMs, {
...process.env,
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
PYTHONPATH: [sdkSrc, process.env.PYTHONPATH]
.filter(Boolean)
.join(delimiter),
UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"),
});
await writeFile(stdoutLog, proc.stdout, "utf8");
@@ -173,7 +196,10 @@ async function main() {
} else if (proc.timedOut) {
result.status = "fail";
result.reason = `ledger stress timed out after ${timeoutMs}ms`;
} else if (proc.status === 0 && proc.stdout.includes("LEDGER_STRESS_OK")) {
} else if (
proc.status === 0 &&
proc.stdout.includes("LEDGER_STRESS_OK")
) {
result.status = "pass";
result.reason = "ledger stress probe passed";
} else {
@@ -194,7 +220,9 @@ async function main() {
await writeFile(resultJson, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
}
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
process.exit(
result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1,
);
}
await main();
@@ -6,9 +6,10 @@ await runPytestProbe({
caseId: "agent-runner-runtime-chaos",
repoEnvKey: "LANGBOT_PLUGIN_SDK_REPO",
defaultRepo: "../../langbot-plugin-sdk",
description: "LangBot plugin SDK AgentRunner runtime failure, timeout, forwarding, and pull API pytest probe.",
description:
"LangBot plugin SDK Runner runtime failure, timeout, forwarding, and pull API pytest probe.",
testTargets: [
"tests/runtime/plugin/test_mgr_agent_runner.py",
"tests/runtime/plugin/test_mgr_runner.py",
"tests/runtime/test_pull_api_handlers.py",
],
});
@@ -5,7 +5,10 @@ import { basename, delimiter, join, resolve } from "node:path";
import { env } from "node:process";
function loadEnvDefaults(root) {
for (const path of [join(root, "skills/.env"), join(root, "skills/.env.local")]) {
for (const path of [
join(root, "skills/.env"),
join(root, "skills/.env.local"),
]) {
if (!existsSync(path)) continue;
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) {
const line = rawLine.trim();
@@ -14,13 +17,20 @@ function loadEnvDefaults(root) {
if (sep === -1) continue;
const key = line.slice(0, sep).trim();
if (env[key]) continue;
env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, "");
env[key] = line
.slice(sep + 1)
.trim()
.replace(/^["']|["']$/g, "");
}
}
}
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
return date
.toISOString()
.replace(/\.\d{3}Z$/, "Z")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-|-$/g, "");
}
function localIsoWithOffset(date = new Date()) {
@@ -88,7 +98,14 @@ async function runProcess(command, timeoutMs, childEnv) {
});
child.on("error", (error) => {
clearTimeout(timeout);
resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null });
resolveDone({
stdout,
stderr,
error,
timedOut,
status: null,
signal: null,
});
});
child.on("close", (status, signal) => {
clearTimeout(timeout);
@@ -109,10 +126,14 @@ export async function runPytestProbe({
}) {
const root = resolve(env.LBS_ROOT || process.cwd());
loadEnvDefaults(root);
const resolvedTimeoutMs = Number(timeoutMs || env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "180000");
const resolvedTimeoutMs = Number(
timeoutMs || env.LANGBOT_RUNNER_PROBE_TIMEOUT_MS || "180000",
);
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
const evidenceDir = resolve(
env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId),
);
await mkdir(evidenceDir, { recursive: true });
const uvCacheDir = env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache");
await mkdir(uvCacheDir, { recursive: true });
@@ -171,14 +192,21 @@ export async function runPytestProbe({
result.status = "env_issue";
result.reason = `${repoEnvKey || "repo"} did not resolve to an existing directory: ${repoPath}`;
} else {
const missingTargets = testTargets.filter((target) => !existsSync(join(repoPath, target.split("::")[0])));
const missingTargets = testTargets.filter(
(target) => !existsSync(join(repoPath, target.split("::")[0])),
);
if (missingTargets.length > 0) {
result.status = "env_issue";
result.reason = `pytest target file(s) not found in ${basename(repoPath)}: ${missingTargets.join(", ")}`;
} else {
const childEnv = { ...process.env, UV_CACHE_DIR: uvCacheDir };
if (pythonPaths.length > 0) {
childEnv.PYTHONPATH = [pythonPaths.join(delimiter), childEnv.PYTHONPATH].filter(Boolean).join(delimiter);
childEnv.PYTHONPATH = [
pythonPaths.join(delimiter),
childEnv.PYTHONPATH,
]
.filter(Boolean)
.join(delimiter);
}
const proc = await runProcess(command, resolvedTimeoutMs, childEnv);
result.exit_status = proc.status;
@@ -195,7 +223,11 @@ export async function runPytestProbe({
} else if (proc.status === 0) {
result.status = "pass";
result.reason = `pytest passed for ${testTargets.join(", ")}.`;
} else if (/command not found|no such file or directory|executable file not found/i.test(`${proc.stdout}\n${proc.stderr}`)) {
} else if (
/command not found|no such file or directory|executable file not found/i.test(
`${proc.stdout}\n${proc.stderr}`,
)
) {
result.status = "env_issue";
result.reason = `pytest command could not run in ${repoPath}. See ${stdoutLog} and ${stderrLog}.`;
} else {
@@ -1,6 +1,6 @@
# AgentRunner QA Workflow
# Runner QA Workflow
Use this workflow when an agent finishes AgentRunner-related code and enters a
Use this workflow when an agent finishes Runner-related code and enters a
test phase.
## Order
@@ -25,7 +25,7 @@ test phase.
backend is available and installing the QA fixture is acceptable.
- `rtk bin/lbs test run agent-runner-qa-debug-chat --dry-run` when WebUI live
execution needs deterministic coverage without a model provider. This
case runs its setup automation first: install the QA AgentRunner fixture,
case runs its setup automation first: install the QA Runner fixture,
create/update the QA pipeline, write the case-specific pipeline env, then
execute Debug Chat.
- `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run`
@@ -51,8 +51,8 @@ only to review or adjust the generated list.
| --- | --- | --- |
| `LangBot/src/langbot/pkg/agent/runner/*`, `tests/unit_tests/agent/test_result_normalizer.py`, protocol/result/context/resource builders | `rtk bin/lbs test run agent-runner-fixture-contract --dry-run`; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot unit tests for touched files | Result shape, user-visible runner output, or Debug Chat delivery changed: add `pipeline-debug-chat` or `local-agent-basic-debug-chat`. |
| `LangBot/src/langbot/pkg/entity/persistence/agent_run.py`, `run_journal.py`, run ledger store/API/auth tests, claim/lease/status code | `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run`; `rtk bin/lbs test run agent-runner-ledger-stress --dry-run`; `rtk bin/lbs test run agent-runner-ledger-contention --dry-run`; `rtk bin/lbs test run agent-runner-async-db-readiness --dry-run` before `rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run` | Debug Chat run lifecycle, resume, or visible completion changed: add `local-agent-basic-debug-chat`. |
| `langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/*`, `api/proxies/agent_run_api.py`, runtime pull handlers, plugin manager/runtime IO | `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted SDK pytest | Runtime delivery or tool-call surface changed: add `agent-runner-release-preflight`, then `local-agent-basic-debug-chat`. |
| `langbot-agent-runner/*/components/agent_runner/*`, external runner daemon/client code, ACP/Codex/Claude runner command wrappers | Repo-local targeted tests; `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-release-preflight --dry-run` | ACP or external coding runner behavior changed: add `acp-agent-runner-debug-chat`. |
| `langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/runner/*`, `api/proxies/agent_run_api.py`, runtime pull handlers, plugin manager/runtime IO | `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted SDK pytest | Runtime delivery or tool-call surface changed: add `agent-runner-release-preflight`, then `local-agent-basic-debug-chat`. |
| `langbot-agent-runner/*/components/runner/*`, external runner daemon/client code, ACP/Codex/Claude runner command wrappers | Repo-local targeted tests; `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-release-preflight --dry-run` | ACP or external coding runner behavior changed: add `acp-agent-runner-debug-chat`. |
| Prompt preprocessing, effective prompt, pipeline AI config, runner binding/default runner migration | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot pipeline/agent tests | The runner reads host-provided prompt or saved runner config: add `local-agent-effective-prompt-debug-chat`. |
| Context window, transcript, history/event state, compaction, checkpoint/steering | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot agent state/context tests | Multi-turn memory, compaction, or steering behavior changed: add `local-agent-context-compaction-debug-chat` and, for steering-specific changes, `local-agent-steering-debug-chat`. |
| Plugin tool authorization, host tool listing, MCP tool bridge, function-call conversion | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted plugin/MCP/tool tests | Tool execution is user-visible: add `local-agent-plugin-tool-call-debug-chat`; for MCP-specific changes add `mcp-stdio-register` then `mcp-stdio-tool-call`. |
@@ -1,4 +1,4 @@
# Agent Runner Release Gate
# Runner Release Gate
Use this reference when judging whether runner externalization is release-ready. The goal is not to enumerate every possible prompt. The gate covers product abilities and trust boundaries with deterministic normal-path cases, then leaves rare negative branches to unit and contract tests.
@@ -35,7 +35,7 @@ For a quick early blocker check, run:
rtk bin/lbs test run agent-runner-release-preflight --dry-run
```
For the code-level AgentRunner probes, run:
For the code-level Runner probes, run:
```bash
rtk bin/lbs test run agent-runner-behavior-matrix --dry-run
@@ -70,7 +70,7 @@ API integration gate, not a Debug Chat execution proof.
`agent-runner-qa-debug-chat` is the deterministic live execution proof. It uses
a pipeline created by `scripts/e2e/ensure-qa-agent-runner-pipeline.mjs` and
expects Debug Chat to return `QA_AGENT_RUNNER_OK:<input>` through
expects Debug Chat to return `QA_RUNNER_OK:<input>` through
`plugin:qa/agent-runner/default`.
`agent-runner-ledger-invariants` is the fast Host ledger probe. It uses
@@ -97,7 +97,7 @@ If it times out before any test result and a direct `aiosqlite.connect()` script
also hangs, classify the run with troubleshooting id
`aiosqlite-connect-hangs` instead of treating it as a browser E2E failure.
`agent-runner-runtime-chaos` runs SDK AgentRunner runtime and pull API handler
`agent-runner-runtime-chaos` runs SDK Runner runtime and pull API handler
tests from `LANGBOT_PLUGIN_SDK_REPO` or `../langbot-plugin-sdk`.
Each probe writes `automation-result.json` and probe logs under
`LBS_EVIDENCE_DIR`.
@@ -108,9 +108,9 @@ Each probe writes `automation-result.json` and probe logs under
| --- | --- | --- |
| Authenticated WebUI session | `webui-login-state`, `agent-runner-release-preflight` | The browser profile can operate the same backend that later cases use. |
| Generic Pipeline Debug Chat | `pipeline-debug-chat` | The WebUI Debug Chat path itself works before runner-specific failures are diagnosed. |
| Deterministic QA runner install | `agent-runner-live-install` | A local `.lbpkg` AgentRunner package can install and register a runner. |
| Deterministic QA runner install | `agent-runner-live-install` | A local `.lbpkg` Runner package can install and register a runner. |
| Deterministic QA runner Debug Chat | `agent-runner-qa-debug-chat` | The installed QA runner executes through WebUI Debug Chat without a model provider. |
| Required runner plugins | `agent-runner-release-preflight` | `langbot-team/LocalAgent` and `langbot-team/ACPAgentRunner` are visible to the host. |
| Required runner plugins | `agent-runner-release-preflight` | `langbot-team/LocalAgent` and `langbot-team/ACPRunner` are visible to the host. |
| Required QA plugin tools | `plugin-e2e-smoke`, `agent-runner-release-preflight`, `qa-plugin-smoke-live-install` | The deterministic `qa_plugin_echo` and `qa_plugin_fail` tools are exposed before tool-loop and tool-error cases start. |
| Knowledge base fixture | `langrag-kb-retrieve`, `local-agent-rag-debug-chat` | LangRAG data is queryable and the runner inserts retrieved context. |
| Effective prompt bridge | `local-agent-effective-prompt-debug-chat` | Host prompt preprocessing reaches the runner. |
@@ -149,7 +149,7 @@ rtk uv run pytest -q
# langbot-plugin-sdk
rtk uv run pytest -q
# langbot-skills saved AgentRunner probes
# langbot-skills saved Runner probes
rtk bin/lbs test run agent-runner-behavior-matrix --dry-run
rtk bin/lbs test run agent-runner-ledger-invariants --dry-run
rtk bin/lbs test run agent-runner-ledger-stress --dry-run
@@ -1,4 +1,4 @@
# Dify AgentRunner
# Dify Runner
Use this reference when validating `langbot-team/DifyAgent` through LangBot WebUI.
@@ -1,4 +1,4 @@
# Local Agent Runner Coverage
# Local Runner Coverage
Use this matrix when judging whether the external `langbot-team/LocalAgent` plugin still behaves like the old built-in local-agent runner.
@@ -10,7 +10,7 @@ The QA target is end-to-end behavior. UI cases prove the host, SDK, plugin runti
- `LangBot/src/langbot/pkg/agent/runner/pipeline_adapter.py` adapts Pipeline-only fields into `ctx.adapter.extra.prompt`, `ctx.adapter.extra.params`, and optional `ctx.bootstrap.messages`.
- `LangBot/src/langbot/pkg/agent/runner/resource_builder.py` authorizes models, fallback models, rerank models, tools, and knowledge bases for the current run.
- `LangBot/src/langbot/pkg/plugin/handler.py` validates run-scoped model/tool/rerank access and calls the host model provider or tool manager with the current query.
- `langbot-local-agent/components/agent_runner/default.py` selects streaming or non-streaming execution, retrieves RAG context, builds messages, invokes models with fallback, and runs tool loops.
- `langbot-local-agent/components/runner/default.py` selects streaming or non-streaming execution, retrieves RAG context, builds messages, invokes models with fallback, and runs tool loops.
- `langbot-local-agent/pkg/messages.py` prefers the host effective prompt from `ctx.adapter.extra.prompt`, uses `ctx.bootstrap.messages` only as a small bootstrap window, and preserves structured/multimodal input while inserting RAG context.
TODO: Treat `ctx.adapter.extra.prompt` as a temporary Pipeline bridge for old
@@ -1,10 +1,10 @@
# Local Agent Runner
# Local Runner
Use this reference when validating the pluginized `langbot-team/LocalAgent` runner through the WebUI.
The goal is behavior parity with the old built-in local-agent runner. The code does not need to be identical, but the visible behavior should match: effective prompt, current input, history, model selection and fallback, tool calling, knowledge retrieval, multimodal input, streaming and non-streaming output all have to reach the runner through the host and SDK.
For path-by-path coverage, read [Local Agent Runner Coverage](local-agent-runner-coverage.md).
For path-by-path coverage, read [Local Runner Coverage](local-agent-runner-coverage.md).
## Main Surface
@@ -35,7 +35,7 @@ Measure user experience and internal composition separately:
- WebUI load and interaction latency.
- Debug Chat send-to-first-visible-token and send-to-completion latency.
- Pipeline, RAG, plugin runtime, MCP, AgentRunner, and persistence segment
- Pipeline, RAG, plugin runtime, MCP, Runner, and persistence segment
latency.
- Queue wait time, concurrency, throughput, timeout rate, and p95/p99 latency.
- Startup, plugin install, knowledge-base ingestion, migration, and recovery
@@ -33,7 +33,7 @@ Both external runners receive the same host-generated gateway `AgentMCPServerCon
This is a **runner-plugin transport detail, not a host all-tool-branch issue** — proven by **both** runners discovering skills end-to-end with the unmodified branch (see cases below).
> **Correction (2026-06-22).** An earlier revision of this doc claimed acp was "blocked" on remote-ssh and *required* `langbot-assets-gateway-public-url`, based on a run that returned `PROBEDONE 0 0` / timeout. That was an **environment artifact, not an acp defect**: a duplicate backend instance (a second checkout `LangBot-master/` whose box runtime contended for the same `--ws-control-port 5410`) plus a wedged plugin runtime (host `emit_event` / `list_agent_runners` action calls timing out with `ActionCallTimeoutError`). Re-run on a clean single-instance runtime, **acp passes via the reverse tunnel with no `public-url`** (`PROBEDONE 1 17`, 824s).
> **Correction (2026-06-22).** An earlier revision of this doc claimed acp was "blocked" on remote-ssh and *required* `langbot-assets-gateway-public-url`, based on a run that returned `PROBEDONE 0 0` / timeout. That was an **environment artifact, not an acp defect**: a duplicate backend instance (a second checkout `LangBot-master/` whose box runtime contended for the same `--ws-control-port 5410`) plus a wedged plugin runtime (host `emit_event` / `list_runners` action calls timing out with `ActionCallTimeoutError`). Re-run on a clean single-instance runtime, **acp passes via the reverse tunnel with no `public-url`** (`PROBEDONE 1 17`, 824s).
- **Lifecycle**: discover → activate → operate (native exec under the activated mount path) → register.
- **Backend**: docker · nsjail · e2b.
@@ -12,7 +12,7 @@ Date: 2026-05-16
### Symptom
The WebUI can send a Debug Chat message, but the bot response is missing or says `Agent runner temporarily unavailable`. Backend logs may include `Action list_plugins call timed out`, `Action list_agent_runners call timed out`, or `Action invoke_llm_stream call timed out`.
The WebUI can send a Debug Chat message, but the bot response is missing or says `Agent runner temporarily unavailable`. Backend logs may include `Action list_plugins call timed out`, `Action list_runners call timed out`, or `Action invoke_llm_stream call timed out`.
### Likely Cause
@@ -78,7 +78,7 @@ Structured entry: `../troubleshooting/marketplace-network-flaky.yaml`
Marketplace icon/tag/recommendation requests can fail while plugin cards are already visible. Retry first, and use backend component endpoints only to confirm installation results.
## agent-runner-actor-context-fields: AgentRunner reads old actor fields
## agent-runner-actor-context-fields: Runner reads old actor fields
Structured entry: `../troubleshooting/agent-runner-actor-context-fields.yaml`
@@ -1,6 +1,6 @@
# Workspace Release Testing
Use the workspace gates when changes span LangBot core, the plugin SDK, AgentRunner, or multiple first-party plugins.
Use the workspace gates when changes span LangBot core, the plugin SDK, Runner, or multiple first-party plugins.
## Cost Ladder
@@ -1,6 +1,6 @@
id: langbot-workspace-release-gate
title: "LangBot workspace top-down release gate"
description: "Broad release gate combining deterministic repository contracts with representative browser workflows, plugin pages, RAG/parser, EBA, external AgentRunner, and one complex LocalAgent task."
description: "Broad release gate combining deterministic repository contracts with representative browser workflows, plugin pages, RAG/parser, EBA, external Runner, and one complex LocalAgent task."
type: release_gate
priority: p0
tags:
@@ -1,9 +1,9 @@
id: agent-runner-actor-context-fields
title: "AgentRunner reads old actor.type and actor.id fields"
title: "Runner reads old actor.type and actor.id fields"
date: 2026-05-17
symptoms:
- "Pipeline Debug Chat shows Agent runner execution failed."
- "Backend logs show an AttributeError from an AgentRunner plugin."
- "Backend logs show an AttributeError from an Runner plugin."
patterns:
- "AttributeError: 'ActorContext' object has no attribute 'type'"
- "AttributeError: 'ActorContext' object has no attribute 'id'"
@@ -15,8 +15,8 @@ fix_steps:
- "Update runner code to read actor.actor_type and actor.actor_id."
- "Keep getattr fallback to type/id only if compatibility with older host data is required."
- "Restart LangBot or the plugin runtime so the updated plugin code is loaded."
- "Add a regression test that builds AgentRunContext with ActorContext(actor_type=..., actor_id=...)."
verification: "Run dify-agent-debug-chat or another AgentRunner Debug Chat and confirm the assistant/bot message contains the expected sentinel while backend logs show Streaming completed."
- "Add a regression test that builds RunnerContext with ActorContext(actor_type=..., actor_id=...)."
verification: "Run dify-agent-debug-chat or another Runner Debug Chat and confirm the assistant/bot message contains the expected sentinel while backend logs show Streaming completed."
related_cases:
- dify-agent-debug-chat
- pipeline-debug-chat
@@ -2,7 +2,7 @@ id: aiosqlite-connect-hangs
title: "aiosqlite connect hangs before ledger pytest starts"
category: env_issue
symptoms:
- "AgentRunner ledger pytest probe times out after collecting tests but before reporting a test result."
- "Runner ledger pytest probe times out after collecting tests but before reporting a test result."
- "pytest stdout stops at a line like tests/unit_tests/agent/test_run_ledger_store.py."
- "A direct aiosqlite.connect(':memory:') script prints its first line and then hangs."
patterns:
@@ -1,5 +1,5 @@
id: ambiguous-runner-default-label
title: "AgentRunner selector shows multiple Default or 默认 options"
title: "Runner selector shows multiple Default or 默认 options"
date: 2026-05-17
symptoms:
- "The Pipeline AI runner selector shows multiple options named Default or 默认."
@@ -9,7 +9,7 @@ patterns:
- "label.zh_Hans: 默认"
- "label.en_US: Default"
likely_causes:
- "AgentRunner component ids are commonly named default, but the user-facing metadata.label was also left generic."
- "Runner component ids are commonly named default, but the user-facing metadata.label was also left generic."
- "The frontend displays metadata.label as the primary option label."
fix_steps:
- "Keep metadata.name as default if the plugin component id is intended to remain stable."
@@ -8,7 +8,7 @@ symptoms:
- "Knowledge sidebar or plugin sidebar loading may hang or time out."
patterns:
- "Action list_plugins call timed out"
- "Action list_agent_runners call timed out"
- "Action list_runners call timed out"
- "Action invoke_llm_stream call timed out"
- "All models failed during streaming setup"
- "Failed to fetch plugins for sidebar"
+65 -34
View File
@@ -4,17 +4,22 @@ import { loadFixtureItems } from "../fixtures.ts";
import { dirname, join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
function fixtureRows(root: string, skill: string | undefined): ReturnType<typeof loadFixtureItems> {
function fixtureRows(
root: string,
skill: string | undefined,
): ReturnType<typeof loadFixtureItems> {
return loadFixtureItems(root, skill);
}
function qaAgentRunnerSourceFindings(item: ReturnType<typeof loadFixtureItems>["items"][number]) {
if (!item.checks.includes("qa_agent_runner_source") || !item.exists) return [];
function qaRunnerSourceFindings(
item: ReturnType<typeof loadFixtureItems>["items"][number],
) {
if (!item.checks.includes("qa_runner_source") || !item.exists) return [];
const root = dirname(item.absolute_path);
const required = [
"main.py",
"components/agent_runner/default.yaml",
"components/agent_runner/default.py",
"components/runner/default.yaml",
"components/runner/default.py",
"assets/icon.svg",
];
const missing = required
@@ -28,15 +33,21 @@ function qaAgentRunnerSourceFindings(item: ReturnType<typeof loadFixtureItems>["
if (missing.length > 0) return missing;
const manifest = readFileSync(item.absolute_path, "utf8");
const runnerYaml = readFileSync(join(root, "components/agent_runner/default.yaml"), "utf8");
const runnerPy = readFileSync(join(root, "components/agent_runner/default.py"), "utf8");
const runnerYaml = readFileSync(
join(root, "components/runner/default.yaml"),
"utf8",
);
const runnerPy = readFileSync(
join(root, "components/runner/default.py"),
"utf8",
);
const requiredText = [
[manifest, "AgentRunner", "manifest.yaml"],
[manifest, "QAAgentRunnerPlugin", "manifest.yaml"],
[runnerYaml, "kind: AgentRunner", "components/agent_runner/default.yaml"],
[runnerYaml, "DefaultAgentRunner", "components/agent_runner/default.yaml"],
[runnerPy, "QA_AGENT_RUNNER_OK", "components/agent_runner/default.py"],
[runnerPy, "QA_AGENT_RUNNER_CONTROLLED_FAILURE", "components/agent_runner/default.py"],
[manifest, "Runner", "manifest.yaml"],
[manifest, "QARunnerPlugin", "manifest.yaml"],
[runnerYaml, "kind: Runner", "components/runner/default.yaml"],
[runnerYaml, "DefaultRunner", "components/runner/default.yaml"],
[runnerPy, "QA_RUNNER_OK", "components/runner/default.py"],
[runnerPy, "QA_RUNNER_CONTROLLED_FAILURE", "components/runner/default.py"],
];
return requiredText
.filter(([text, needle]) => !text.includes(needle))
@@ -49,16 +60,22 @@ function qaAgentRunnerSourceFindings(item: ReturnType<typeof loadFixtureItems>["
}));
}
function zipPackageFindings(item: ReturnType<typeof loadFixtureItems>["items"][number]) {
function zipPackageFindings(
item: ReturnType<typeof loadFixtureItems>["items"][number],
) {
if (!item.checks.includes("zip_package") || !item.exists) return [];
const header = readFileSync(item.absolute_path).subarray(0, 4).toString("binary");
const header = readFileSync(item.absolute_path)
.subarray(0, 4)
.toString("binary");
if (header === "PK\u0003\u0004" || header === "PK\u0005\u0006") return [];
return [{
severity: "fail",
kind: "fixture_check_invalid_zip",
id: item.id,
path: item.path,
}];
return [
{
severity: "fail",
kind: "fixture_check_invalid_zip",
id: item.id,
path: item.path,
},
];
}
export function commandFixtureList(ctx: CommandContext): number {
@@ -72,14 +89,16 @@ export function commandFixtureList(ctx: CommandContext): number {
}
for (const item of result.items) {
console.log([
item.skill,
item.id,
item.kind,
item.exists ? "present" : "missing",
item.path,
item.title,
].join("\t"));
console.log(
[
item.skill,
item.id,
item.kind,
item.exists ? "present" : "missing",
item.path,
item.title,
].join("\t"),
);
}
for (const error of result.errors) console.error(`ERROR: ${error}`);
return result.errors.length > 0 ? 1 : 0;
@@ -90,7 +109,11 @@ export function commandFixtureCheck(ctx: CommandContext): number {
const skill = positional[0];
const result = fixtureRows(ctx.root, skill);
const findings = [
...result.errors.map((error) => ({ severity: "fail", kind: "invalid_manifest", detail: error })),
...result.errors.map((error) => ({
severity: "fail",
kind: "invalid_manifest",
detail: error,
})),
...result.items
.filter((item) => !item.exists)
.map((item) => ({
@@ -100,11 +123,13 @@ export function commandFixtureCheck(ctx: CommandContext): number {
path: item.path,
absolute_path: item.absolute_path,
})),
...result.items.flatMap(qaAgentRunnerSourceFindings),
...result.items.flatMap(qaRunnerSourceFindings),
...result.items.flatMap(zipPackageFindings),
];
const report = {
status: findings.some((finding) => finding.severity === "fail") ? "fail" : "pass",
status: findings.some((finding) => finding.severity === "fail")
? "fail"
: "pass",
fixture_count: result.items.length,
findings,
fixtures: result.items,
@@ -120,12 +145,18 @@ export function commandFixtureCheck(ctx: CommandContext): number {
console.log("");
console.log("## Fixtures");
for (const item of result.items) {
console.log(`- ${item.id}: ${item.exists ? "present" : "missing"} (${item.path})`);
console.log(
`- ${item.id}: ${item.exists ? "present" : "missing"} (${item.path})`,
);
}
console.log("");
console.log("## Findings");
if (findings.length === 0) console.log("- None.");
else for (const finding of findings) console.log(`- [${finding.severity}] ${finding.kind}: ${"detail" in finding ? finding.detail : finding.id}`);
else
for (const finding of findings)
console.log(
`- [${finding.severity}] ${finding.kind}: ${"detail" in finding ? finding.detail : finding.id}`,
);
}
return report.status === "pass" ? 0 : 1;
File diff suppressed because it is too large Load Diff
+151 -71
View File
@@ -51,12 +51,22 @@ import { commandValidate } from "../src/commands/validate.ts";
import { commandIndex } from "../src/commands/skill.ts";
import { loadEnv, parseFrontmatter } from "../src/fs.ts";
test('frontmatter preserves metadata and body with LF and CRLF checkouts', () => {
for (const newline of ['\n', '\r\n']) {
const source = ['---', 'name: example', 'description: "Example skill"', '---', '# Body', ''].join(newline);
test("frontmatter preserves metadata and body with LF and CRLF checkouts", () => {
for (const newline of ["\n", "\r\n"]) {
const source = [
"---",
"name: example",
'description: "Example skill"',
"---",
"# Body",
"",
].join(newline);
const parsed = parseFrontmatter(source);
assert.deepEqual(parsed.meta, { name: 'example', description: 'Example skill' });
assert.equal(parsed.body, '# Body' + newline);
assert.deepEqual(parsed.meta, {
name: "example",
description: "Example skill",
});
assert.equal(parsed.body, "# Body" + newline);
}
});
import { repoRoot } from "../src/cli.ts";
@@ -123,19 +133,25 @@ test("clickFirstVisible waits for a later visible DOM match", async () => {
let clickedIndex = -1;
const emptyLocator = {
count: async () => 0,
nth: () => { throw new Error("empty locator has no children"); },
nth: () => {
throw new Error("empty locator has no children");
},
};
const textLocator = {
count: async () => 2,
nth: (index: number) => ({
isVisible: async () => index === 1 && pollCount >= 1,
click: async () => { clickedIndex = index; },
click: async () => {
clickedIndex = index;
},
}),
};
const page = {
getByRole: () => emptyLocator,
getByText: () => textLocator,
waitForTimeout: async () => { pollCount += 1; },
waitForTimeout: async () => {
pollCount += 1;
},
};
const clicked = await clickFirstVisible(page, ["Debug Chat"], 1_000);
@@ -349,7 +365,9 @@ test("apiJson bootstraps and sends the selected Workspace for scoped APIs", asyn
return new Response(
JSON.stringify({
code: 0,
data: { workspaces: [{ workspace: { uuid: "workspace-api-test" } }] },
data: {
workspaces: [{ workspace: { uuid: "workspace-api-test" } }],
},
}),
{ status: 200 },
);
@@ -359,15 +377,16 @@ test("apiJson bootstraps and sends the selected Workspace for scoped APIs", asyn
});
}) as typeof fetch;
const response = await apiJson(
"http://127.0.0.1:5300",
"/api/v1/tools",
{ token: "workspace-api-token" },
);
const response = await apiJson("http://127.0.0.1:5300", "/api/v1/tools", {
token: "workspace-api-token",
});
assert.equal(response.status, 200);
assert.equal(requests.length, 2);
assert.equal(requests[0].url, "http://127.0.0.1:5300/api/v1/workspaces/bootstrap");
assert.equal(
requests[0].url,
"http://127.0.0.1:5300/api/v1/workspaces/bootstrap",
);
assert.equal(requests[0].headers["X-Workspace-Id"], undefined);
assert.equal(requests[1].headers["X-Workspace-Id"], "workspace-api-test");
} finally {
@@ -623,9 +642,7 @@ test("index includes case summaries for agent discovery", () => {
}) =>
item.id === "agent-runner-qa-debug-chat" &&
item.setup_automation.includes("case:agent-runner-live-install") &&
item.setup_provides_env.includes(
"LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL",
),
item.setup_provides_env.includes("LANGBOT_QA_RUNNER_PIPELINE_URL"),
),
);
assert.ok(
@@ -1908,12 +1925,12 @@ test("fixture check reports missing manifest paths", () => {
}
});
test("fixture check verifies QA AgentRunner source shape", () => {
test("fixture check verifies QA Runner source shape", () => {
const tmp = mkdtempSync(join(tmpdir(), "lbs-fixture-check-"));
try {
const skillDir = join(tmp, "skills", "langbot-testing");
const fixtureDir = join(skillDir, "fixtures", "plugins", "qa-agent-runner");
mkdirSync(join(fixtureDir, "components", "agent_runner"), {
mkdirSync(join(fixtureDir, "components", "runner"), {
recursive: true,
});
writeFileSync(
@@ -1925,15 +1942,15 @@ test("fixture check verifies QA AgentRunner source shape", () => {
JSON.stringify([
{
id: "qa-agent-runner-source",
title: "QA AgentRunner",
title: "QA Runner",
path: "fixtures/plugins/qa-agent-runner/manifest.yaml",
checks: ["exists", "qa_agent_runner_source"],
checks: ["exists", "qa_runner_source"],
},
]),
);
writeFileSync(
join(fixtureDir, "manifest.yaml"),
"spec:\n components:\n AgentRunner: {}\nexecution:\n python:\n attr: QAAgentRunnerPlugin\n",
"spec:\n components:\n Runner: {}\nexecution:\n python:\n attr: QARunnerPlugin\n",
);
const result = capture(() =>
@@ -1949,7 +1966,7 @@ test("fixture check verifies QA AgentRunner source shape", () => {
report.findings.some(
(finding: { kind?: string; path?: string }) =>
finding.kind === "fixture_check_missing_file" &&
finding.path?.endsWith("components/agent_runner/default.py"),
finding.path?.endsWith("components/runner/default.py"),
),
);
} finally {
@@ -1957,7 +1974,7 @@ test("fixture check verifies QA AgentRunner source shape", () => {
}
});
test("fixture check accepts complete QA AgentRunner source shape", () => {
test("fixture check accepts complete QA Runner source shape", () => {
const result = capture(() =>
commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"])),
);
@@ -1967,7 +1984,7 @@ test("fixture check accepts complete QA AgentRunner source shape", () => {
report.fixtures.some(
(item: { id: string; checks: string[] }) =>
item.id === "qa-agent-runner-source" &&
item.checks.includes("qa_agent_runner_source"),
item.checks.includes("qa_runner_source"),
),
);
});
@@ -2091,9 +2108,17 @@ test("debug chat classifier distinguishes new failure signals from old history",
test("debug chat outcome wait can stop on a new failure signal", () => {
const baselines = [{ signal: "runner.timeout", count: 1 }];
assert.equal(hasDebugChatOutcome("old runner.timeout", "EXPECTED", 1, baselines), false);
assert.equal(
hasDebugChatOutcome("old runner.timeout\nnew runner.timeout", "EXPECTED", 1, baselines),
hasDebugChatOutcome("old runner.timeout", "EXPECTED", 1, baselines),
false,
);
assert.equal(
hasDebugChatOutcome(
"old runner.timeout\nnew runner.timeout",
"EXPECTED",
1,
baselines,
),
true,
);
assert.equal(hasDebugChatOutcome("EXPECTED", "EXPECTED", 1, baselines), true);
@@ -2215,7 +2240,13 @@ test("debug chat classifier rejects a matching assistant message that is not fin
});
test("debug chat classifier accepts formatted responses containing every required fragment", () => {
const expectedTexts = ["MULTITOOL_COMBO_FINAL", "passcode-6718", "rag-7421", "tool-a", "tool-b"];
const expectedTexts = [
"MULTITOOL_COMBO_FINAL",
"passcode-6718",
"rag-7421",
"tool-a",
"tool-b",
];
const result = classifyDebugChatResult({
beforeText: "",
afterText: "Bot response with formatted details",
@@ -2225,11 +2256,14 @@ test("debug chat classifier accepts formatted responses containing every require
latestExpectedLeaf: "MULTITOOL_COMBO_FINAL",
latestFailureLeaf: "",
beforeMessages: [],
afterMessages: [{
role: "assistant",
text: "MULTITOOL_COMBO_FINAL\n- passcode-6718\n- rag-7421\n- tool-a\n- tool-b",
}],
latestAssistantText: "MULTITOOL_COMBO_FINAL\n- passcode-6718\n- rag-7421\n- tool-a\n- tool-b",
afterMessages: [
{
role: "assistant",
text: "MULTITOOL_COMBO_FINAL\n- passcode-6718\n- rag-7421\n- tool-a\n- tool-b",
},
],
latestAssistantText:
"MULTITOOL_COMBO_FINAL\n- passcode-6718\n- rag-7421\n- tool-a\n- tool-b",
});
assert.equal(result.status, "pass");
@@ -2246,7 +2280,9 @@ test("debug chat classifier rejects formatted responses missing a required fragm
latestExpectedLeaf: "MULTITOOL_COMBO_FINAL",
latestFailureLeaf: "",
beforeMessages: [],
afterMessages: [{ role: "assistant", text: "MULTITOOL_COMBO_FINAL\n- tool-a" }],
afterMessages: [
{ role: "assistant", text: "MULTITOOL_COMBO_FINAL\n- tool-a" },
],
latestAssistantText: "MULTITOOL_COMBO_FINAL\n- tool-a",
});
@@ -2608,7 +2644,7 @@ test("generic pipeline readiness accepts either URL or name target", () => {
}
});
test("test recommend maps AgentRunner ledger changes to focused probes", () => {
test("test recommend maps Runner ledger changes to focused probes", () => {
const result = capture(() =>
commandTestRecommend(
ctx([
@@ -2642,14 +2678,14 @@ test("test recommend maps AgentRunner ledger changes to focused probes", () => {
);
});
test("test recommend maps AgentRunner result changes to fixture contract", () => {
test("test recommend maps Runner result changes to fixture contract", () => {
const result = capture(() =>
commandTestRecommend(
ctx([
"test",
"recommend",
"--file",
"langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/result.py",
"langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/runner/result.py",
"--json",
]),
),
@@ -2662,14 +2698,14 @@ test("test recommend maps AgentRunner result changes to fixture contract", () =>
assert.ok(!ids.includes("agent-runner-ledger-invariants"));
});
test("test recommend maps QA AgentRunner fixture changes to live install", () => {
test("test recommend maps QA Runner fixture changes to live install", () => {
const result = capture(() =>
commandTestRecommend(
ctx([
"test",
"recommend",
"--file",
"langbot-skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py",
"langbot-skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/runner/default.py",
"--json",
]),
),
@@ -2705,7 +2741,7 @@ test("test recommend keeps git status paths intact", () => {
const originalRepos = {
LANGBOT_REPO: process.env.LANGBOT_REPO,
LANGBOT_PLUGIN_SDK_REPO: process.env.LANGBOT_PLUGIN_SDK_REPO,
LANGBOT_AGENT_RUNNER_REPO: process.env.LANGBOT_AGENT_RUNNER_REPO,
LANGBOT_RUNNER_REPO: process.env.LANGBOT_RUNNER_REPO,
LANGBOT_LOCAL_AGENT_REPO: process.env.LANGBOT_LOCAL_AGENT_REPO,
};
try {
@@ -2752,7 +2788,7 @@ test("test recommend keeps git status paths intact", () => {
process.env.LANGBOT_REPO = repo;
process.env.LANGBOT_PLUGIN_SDK_REPO = join(tmp, "missing-sdk");
process.env.LANGBOT_AGENT_RUNNER_REPO = join(tmp, "missing-runner");
process.env.LANGBOT_RUNNER_REPO = join(tmp, "missing-runner");
process.env.LANGBOT_LOCAL_AGENT_REPO = join(tmp, "missing-local");
const result = capture(() =>
commandTestRecommend({ root, args: ["test", "recommend", "--json"] }),
@@ -3763,10 +3799,20 @@ test("fake provider can inject faults for only the selected model", async () =>
});
assert.equal(fakeProviderMessage(fallback).content, "OK");
const state = await fetch(`${rootUrl}/__qa/config`).then((response) => response.json());
const state = await fetch(`${rootUrl}/__qa/config`).then((response) =>
response.json(),
);
assert.deepEqual(
state.recent_requests.map((request: { model: string; status: string }) => [request.model, request.status]),
[["qa-primary", "http_fault"], ["qa-fallback", "ok"]],
state.recent_requests.map(
(request: { model: string; status: string }) => [
request.model,
request.status,
],
),
[
["qa-primary", "http_fault"],
["qa-fallback", "ok"],
],
);
} finally {
await provider.stop();
@@ -3776,38 +3822,64 @@ test("fake provider can inject faults for only the selected model", async () =>
test("local-agent model failure cases expose fallback fault controls", () => {
const beforeFirstChunk = capture(() =>
commandTestRun(
ctx(["test", "run", "local-agent-model-fallback-before-first-chunk-debug-chat", "--dry-run", "--json"]),
ctx([
"test",
"run",
"local-agent-model-fallback-before-first-chunk-debug-chat",
"--dry-run",
"--json",
]),
),
);
assert.equal(beforeFirstChunk.code, 0);
const fallbackRun = JSON.parse(beforeFirstChunk.output);
assert.equal(fallbackRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_MODEL_NAME, "qa-fallback-primary");
assert.equal(
fallbackRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_FALLBACK_MODEL_NAMES,
fallbackRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_MODEL_NAME,
"qa-fallback-primary",
);
assert.equal(
fallbackRun.automation.env_defaults
.LANGBOT_FAKE_PROVIDER_FALLBACK_MODEL_NAMES,
"qa-fallback-secondary",
);
assert.equal(fallbackRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_FAIL_MODELS, "qa-fallback-primary");
assert.equal(
fallbackRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_FAIL_MODELS,
"qa-fallback-primary",
);
const postCommit = capture(() =>
commandTestRun(
ctx(["test", "run", "local-agent-streaming-post-commit-failure-debug-chat", "--dry-run", "--json"]),
ctx([
"test",
"run",
"local-agent-streaming-post-commit-failure-debug-chat",
"--dry-run",
"--json",
]),
),
);
assert.equal(postCommit.code, 0);
const postCommitRun = JSON.parse(postCommit.output);
assert.equal(
postCommitRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK_MODELS,
postCommitRun.automation.env_defaults
.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK_MODELS,
"qa-post-commit-primary",
);
assert.equal(
postCommitRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK_DELAY_MS,
postCommitRun.automation.env_defaults
.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK_DELAY_MS,
"1000",
);
assert.equal(
postCommitRun.automation.env_defaults.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK_MODE,
postCommitRun.automation.env_defaults
.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK_MODE,
"error_event",
);
assert.equal(postCommitRun.automation.env_defaults.LANGBOT_DEBUG_CHAT_LOAD_REQUIRE_SUCCESS, "false");
assert.equal(
postCommitRun.automation.env_defaults
.LANGBOT_DEBUG_CHAT_LOAD_REQUIRE_SUCCESS,
"false",
);
});
test("fake provider requires the effective system prompt before returning its sentinel", async () => {
@@ -3854,10 +3926,13 @@ test("fake provider preserves a unique qa_mcp_echo probe value across the tool l
const initial = fakeProviderMessage(
await requestFakeProvider(provider, {
tools: [tool],
messages: [{
role: "user",
content: "Call qa_mcp_echo with exactly this text: box-recovery-unique-42. Return only the tool result.",
}],
messages: [
{
role: "user",
content:
"Call qa_mcp_echo with exactly this text: box-recovery-unique-42. Return only the tool result.",
},
],
}),
);
assert.equal(initial.tool_calls?.[0]?.function?.name, "qa_mcp_echo");
@@ -3872,10 +3947,15 @@ test("fake provider preserves a unique qa_mcp_echo probe value across the tool l
messages: [
{
role: "user",
content: "Call qa_mcp_echo with exactly this text: box-recovery-unique-42. Return only the tool result.",
content:
"Call qa_mcp_echo with exactly this text: box-recovery-unique-42. Return only the tool result.",
},
initial,
{ role: "tool", tool_call_id: initial.tool_calls?.[0]?.id, content: "qa_mcp_echo:box-recovery-unique-42" },
{
role: "tool",
tool_call_id: initial.tool_calls?.[0]?.id,
content: "qa_mcp_echo:box-recovery-unique-42",
},
],
}),
);
@@ -4057,7 +4137,7 @@ test("generic pipeline automation can still use the shared pipeline env", () =>
);
});
test("AgentRunner live install case exposes package automation defaults", () => {
test("Runner live install case exposes package automation defaults", () => {
const result = capture(() =>
commandTestRun(
ctx(["test", "run", "agent-runner-live-install", "--dry-run", "--json"]),
@@ -4106,7 +4186,7 @@ test("QA plugin live install checks the fixture package before installed state",
}
});
test("AgentRunner QA Debug Chat case uses dedicated pipeline env", () => {
test("Runner QA Debug Chat case uses dedicated pipeline env", () => {
const result = capture(() =>
commandTestRun(
ctx(["test", "run", "agent-runner-qa-debug-chat", "--dry-run", "--json"]),
@@ -4135,12 +4215,12 @@ test("AgentRunner QA Debug Chat case uses dedicated pipeline env", () => {
run.automation.env_aliases.some(
(alias: { target: string; source: string }) =>
alias.target === "LANGBOT_E2E_PIPELINE_URL" &&
alias.source === "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL",
alias.source === "LANGBOT_QA_RUNNER_PIPELINE_URL",
),
);
});
test("AgentRunner QA Debug Chat setup automation removes manual readiness", () => {
test("Runner QA Debug Chat setup automation removes manual readiness", () => {
withEnv(
{
LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile",
@@ -4156,8 +4236,8 @@ test("AgentRunner QA Debug Chat setup automation removes manual readiness", () =
const plan = JSON.parse(planResult.output);
assert.equal(plan.manual_readiness.status, "not_required");
assert.deepEqual(plan.setup_provides_env, [
"LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL",
"LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME",
"LANGBOT_QA_RUNNER_PIPELINE_URL",
"LANGBOT_QA_RUNNER_PIPELINE_NAME",
]);
assert.equal(plan.automation_readiness.status, "ready");
@@ -4177,7 +4257,7 @@ test("AgentRunner QA Debug Chat setup automation removes manual readiness", () =
);
});
test("ACP AgentRunner Debug Chat case setups the ACP pipeline env", () => {
test("ACP Runner Debug Chat case setups the ACP pipeline env", () => {
const result = capture(() =>
commandTestRun(
ctx([
@@ -4199,7 +4279,7 @@ test("ACP AgentRunner Debug Chat case setups the ACP pipeline env", () => {
run.automation.env_aliases.some(
(alias: { target: string; source: string }) =>
alias.target === "LANGBOT_E2E_PIPELINE_URL" &&
alias.source === "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL",
alias.source === "LANGBOT_ACP_RUNNER_PIPELINE_URL",
),
);
@@ -4211,8 +4291,8 @@ test("ACP AgentRunner Debug Chat case setups the ACP pipeline env", () => {
assert.equal(planResult.code, 0);
const plan = JSON.parse(planResult.output);
assert.deepEqual(plan.setup_provides_env, [
"LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL",
"LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME",
"LANGBOT_ACP_RUNNER_PIPELINE_URL",
"LANGBOT_ACP_RUNNER_PIPELINE_NAME",
]);
assert.ok(
!plan.preconditions.some((item: string) =>
@@ -4885,7 +4965,7 @@ test("test report classifies provider quota tracebacks as env_issue", () => {
logPath,
[
"[05-21 10:31:00.000] chat.py (2) - [ERROR] : Request Failed: Traceback (most recent call last):",
" File \"provider.py\", line 1, in invoke",
' File "provider.py", line 1, in invoke',
"openai.PermissionDeniedError: insufficient user quota",
"[05-21 10:31:01.000] pipeline.py (3) - [ERROR] : runner.llm_error All models failed during streaming setup: insufficient user quota",
].join("\n"),