mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(runner): unify plugin execution across agents and event processors
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 后产生假阳性。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
-39
@@ -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",
|
||||
)
|
||||
+39
@@ -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',
|
||||
)
|
||||
+2
-2
@@ -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`, 8–24s).
|
||||
> **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`, 8–24s).
|
||||
- **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:
|
||||
|
||||
+4
-4
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
+668
-232
File diff suppressed because it is too large
Load Diff
+151
-71
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user