fix(agent-runner): align plugin runner runtime boundaries

This commit is contained in:
huanghuoguoguo
2026-06-05 09:35:17 +08:00
parent 36292102f9
commit 121a736e6a
14 changed files with 189 additions and 101 deletions

View File

@@ -19,7 +19,7 @@ langbot-app/
claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ...
```
后续可聚合进 monorepo也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 在官方插件迁移完成前保留作为行为对齐基准,不作为长期运行路径
后续可聚合进 monorepo也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback
## 2. 插件命名和 runner id
@@ -135,13 +135,13 @@ workspace lifecycle、secret projection、团队级 audit 或 runtime sidecar
## 8. 发布和安装策略
最终 LangBot 安装/升级时需保证官方 runner 插件可用可选方案首次启动检测缺失并提示安装打包发行版预装migration 前检查插件存在性。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 历史配置 migration 只在官方插件可用时执行 → 迁移期间保留旧内置 runner 文件,直到对应官方插件通过 parity 验收
最终 LangBot 安装/升级时需保证官方 runner 插件可用可选方案首次启动检测缺失并提示安装打包发行版预装migration 前检查插件存在性。当前分支未发布,因此不把历史配置兼容或旧内置 runner fallback 写入运行时协议面。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 若发布升级需要迁移历史配置,再在 release gate 中实现一次性 migration 并要求官方插件可用。
## 9. 验收标准
- 每个 runner 都有对应官方 AgentRunner 插件,旧配置能无损复制到新 `runner_config[id]`
- 每个目标 runner 都有对应官方 AgentRunner 插件和稳定 runner id当前配置只使用 `ai.runner.id` + `ai.runner_config[id]`
- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。
- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。
- `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。
- `claude-code-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state并通过 WebUI Debug Chat smoke。
- 对外行为与旧内置 local-agent runner 一致;代码结构不需要相同。
- `local-agent` 覆盖旧内置 runner 的用户可见核心能力;代码结构和运行路径不需要相同。

View File

@@ -112,6 +112,8 @@ bin/lbs case list
- runner 选项来自插件 registry。
- 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`
- `runner_config` 表示 Agent/runner config不表示插件实例状态。
- 不读取或回写旧 `ai.runner.runner` 字段。
- 不出现旧内置 runner stage 名(例如裸 `local-agent`)作为当前选中项或配置 surface。
- 插件没有循环重启或 metadata 加载失败。
### 5.2 主聊天路径
@@ -208,6 +210,7 @@ Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为
| 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 |
| run_id 结束后复用被拒绝 | session registry 注销测试。 |
| 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 |
| 绑定插件身份的 run_id 省略 caller identity 被拒绝 | `_validate_run_authorization(..., caller_plugin_identity=None)` 返回错误。 |
| storage/state scope 越权被拒绝 | state/storage proxy 单测。 |
如果这些单测失败,不能用 WebUI 正常回复替代。

View File

@@ -6,7 +6,7 @@
## 总体进度
**当前阶段**: Phase 3.5 已完成Event-first 基础设施已完成2026-05-29 已通过本地 `local-agent` 与 Claude Code runner smoke
**当前阶段**: Phase 3.6 已完成Event-first 基础设施与外部 harness runner smoke 已完成2026-06-04 已完成协议 / 文档漂移复核,当前未发布分支不保留 PoC 兼容 shim
| Phase | 描述 | 状态 |
|-------|------|------|
@@ -92,6 +92,7 @@
| 2026-05-29 | Claude Code context / skill / MCP projection | ✅ PASS | `langbot-skills/reports/claude-code-agent-resource-context-20260529.md` |
| 2026-05-29 | Claude Code resume state | ✅ PASS | `langbot-skills/reports/claude-code-agent-real-workdir-20260529.md` |
| 2026-05-29 | `codex-agent` Debug Chat + thread_id resume state | ✅ PASS | 见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) §10 / `langbot-skills/reports/` |
| 2026-06-04 | 协议 / 文档漂移复核 | ✅ PASS | SDK scaffold 与 Protocol v1 对齐LangBot UI 旧 runner fallback 已移除run-scoped API 身份校验已收紧。 |
---
@@ -100,7 +101,7 @@
以下项目属于本分支收尾工作:
- [x] Smoke / manual validation — `local-agent`、Claude Code MVP、Codex MVP 已通过本地 WebUI smoke
- [ ] Docs final QA
- [x] Docs final QA — 2026-06-04 已完成当前 Protocol v1 / scaffold / QA 指南漂移复核
- [ ] Claude Code runner 文档、安装和 marketplace 发布准备
---
@@ -130,7 +131,7 @@
- [x] Pipeline `run_from_query()``run(event, binding)` — 已完成
- [x] EventLog / Transcript / ArtifactStore / PersistentStateStore — 已完成
- [x] History / Event / Artifact / State pull APIs — 已完成
- [x] `caller_plugin_identity` 验证路径 — 已完成
- [x] `caller_plugin_identity` 验证路径 — 已完成run-scoped session 绑定插件身份时,省略或不匹配 caller identity 都会被拒绝
### 低优先级 / 未来
@@ -148,6 +149,7 @@
| 2026-05-13 | Phase 3 完成:所有 7 个官方 runner 插件迁移完成 |
| 2026-05-23 | Phase 3.5 完成:`run_from_query()` 委托到 event-first `run(event, binding)`Pipeline path 获得 host capabilities |
| 2026-05-29 | 本地 `local-agent``claude-code-agent` 通过 WebUI smokeClaude Code runner 验证 external harness context 投影和 host-owned resume state |
| 2026-06-04 | 未发布协议面收敛:移除旧 runner 字段 / 旧本地 runner 名 / PoC schema 兼容分支SDK 文档和模板对齐当前 `AgentRunContext` |
---

View File

@@ -264,7 +264,7 @@ class AgentRunOrchestrator:
# Convert Query to event-first envelope
event = QueryEntryAdapter.query_to_event(query)
# Project legacy Pipeline config into target Agent config, then resolve
# Project the current Pipeline adapter config into target Agent config.
# exactly one effective binding for this event.
agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id)
binding = self.binding_resolver.resolve_one(event, [agent_config])

View File

@@ -146,13 +146,19 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
echo "python3 or python is required to prepare the workspace Python environment" >&2
exit 127
fi
export TMPDIR="$_LB_TMP_DIR"
export TEMP="$_LB_TMP_DIR"
export TMP="$_LB_TMP_DIR"
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
_lb_python_meta() {{
python - <<'PY'
"$_LB_SYSTEM_PYTHON" - <<'PY'
import hashlib
import json
import os
@@ -225,7 +231,7 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
rm -rf "$_LB_VENV_DIR"
python -m venv "$_LB_VENV_DIR"
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
. "$_LB_VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
if [ -f "{mount_path}/requirements.txt" ]; then

View File

@@ -153,7 +153,7 @@ class PreProcessor(stage.PipelineStage):
stage_inst_name: str,
) -> entities.StageProcessResult:
"""Process"""
# Resolve runner ID using ConfigMigration (supports both new and old formats)
# Resolve runner ID from the current ai.runner.id shape.
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
# Get runner config from ai.runner_config[runner_id].

View File

@@ -297,7 +297,8 @@ async def _validate_run_authorization(
resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage', 'file').
resource_id: Resource identifier (model_uuid, tool_name, kb_id, 'plugin'/'workspace', file_key).
ap: Application instance for logging.
caller_plugin_identity: Optional plugin identity (author/name) of the caller for cross-plugin validation.
caller_plugin_identity: Plugin identity (author/name) of the caller.
Required when the run session is bound to a plugin identity.
Returns:
Tuple of (session, None) if validation passes.
@@ -313,10 +314,13 @@ async def _validate_run_authorization(
message=f'Run session {run_id} not found or expired',
)
# Validate caller_plugin_identity matches session's plugin_identity
if caller_plugin_identity:
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity and caller_plugin_identity != session_plugin_identity:
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return None, handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}',
)
if caller_plugin_identity != session_plugin_identity:
ap.logger.warning(
f'{resource_type.upper()}: caller_plugin_identity {caller_plugin_identity} '
f'does not match session plugin_identity {session_plugin_identity}'

View File

@@ -18,6 +18,7 @@ from ....box.workspace import (
rewrite_mounted_path,
rewrite_venv_command,
unwrap_venv_path,
wrap_python_command_with_env,
)
if TYPE_CHECKING:
@@ -128,6 +129,7 @@ class BoxStdioSessionRuntime:
workspace = self._build_workspace(host_path=None)
host_path = self.resolve_host_path()
process_cwd = '/workspace'
install_cmd: str | None = None
try:
await workspace.create_session()
@@ -168,6 +170,8 @@ class BoxStdioSessionRuntime:
env=self.server_config.get('env', {}),
cwd=process_cwd,
)
if install_cmd:
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
payload['process_id'] = self.process_id
await workspace.box_service.start_managed_process(workspace.session_id, payload)
except Exception:
@@ -328,23 +332,31 @@ class BoxStdioSessionRuntime:
@staticmethod
def detect_install_command(host_path: str, workspace_path: str = '/workspace') -> str | None:
workspace_kind = classify_python_workspace(host_path)
quoted_workspace_path = shlex.quote(workspace_path)
if workspace_kind == 'package':
return (
'mkdir -p /opt/_lb_src'
f' && tar -C {quoted_workspace_path}'
' --exclude=.venv --exclude=.git --exclude=__pycache__'
' --exclude=node_modules --exclude=.tox --exclude=.nox'
' --exclude="*.egg-info" --exclude=.uv-cache'
' -cf - .'
' | tar -C /opt/_lb_src -xf -'
' && pip install --no-cache-dir /opt/_lb_src'
' && rm -rf /opt/_lb_src'
)
if workspace_kind == 'requirements':
return f'pip install --no-cache-dir -r {quoted_workspace_path}/requirements.txt'
if workspace_kind in {'package', 'requirements'}:
return wrap_python_command_with_env('python -c "pass"', mount_path=workspace_path).rstrip()
return None
@staticmethod
def _wrap_process_payload_with_python_env(payload: dict[str, Any], workspace_path: str) -> dict[str, Any]:
"""Start a prepared Python workspace without writing bootstrap output to MCP stdio."""
workspace_root = workspace_path.rstrip('/') or '/workspace'
venv_dir = f'{workspace_root}/.venv'
venv_bin = f'{venv_dir}/bin'
command = ' '.join(
[shlex.quote(payload['command']), *[shlex.quote(arg) for arg in payload.get('args', [])]]
)
wrapped = dict(payload)
wrapped['command'] = 'sh'
wrapped['args'] = [
'-lc',
(
f'export VIRTUAL_ENV={shlex.quote(venv_dir)}; '
f'export PATH={shlex.quote(venv_bin)}:$PATH; '
f'exec {command}'
),
]
return wrapped
def build_box_session_payload(self, session_id: str, host_path: str | None = None) -> dict[str, Any]:
workspace = self._build_workspace()
workspace.session_id = session_id

View File

@@ -1256,7 +1256,13 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization('run_validate_test_helper', 'model', 'model_001', mock_ap)
session, error = await _validate_run_authorization(
'run_validate_test_helper',
'model',
'model_001',
mock_ap,
caller_plugin_identity='test/runner',
)
# Should return session, no error
assert session is not None
@@ -1310,6 +1316,7 @@ class TestValidateRunAuthorizationHelper:
'model',
'model_999', # Not in resources
mock_ap,
caller_plugin_identity='test/runner',
)
# Should return no session, error response
@@ -1342,7 +1349,13 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization('run_tool_test_helper', 'tool', 'web_search', mock_ap)
session, error = await _validate_run_authorization(
'run_tool_test_helper',
'tool',
'web_search',
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is not None
assert error is None
@@ -1371,7 +1384,13 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization('run_kb_test_helper', 'knowledge_base', 'kb_001', mock_ap)
session, error = await _validate_run_authorization(
'run_kb_test_helper',
'knowledge_base',
'kb_001',
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is not None
assert error is None
@@ -1548,7 +1567,13 @@ class TestRealActionHandlerSimulation:
mock_ap.logger = MagicMock()
# Step 1: Validate authorization
session, error = await _validate_run_authorization('run_invoke_llm_flow_sim', 'model', 'model_001', mock_ap)
session, error = await _validate_run_authorization(
'run_invoke_llm_flow_sim',
'model',
'model_001',
mock_ap,
caller_plugin_identity='test/runner',
)
# Should pass authorization
assert session is not None
@@ -1582,7 +1607,13 @@ class TestRealActionHandlerSimulation:
mock_ap.logger.warning = MagicMock()
# Try to access unauthorized model
session, error = await _validate_run_authorization('run_reject_model_sim', 'model', 'model_999', mock_ap)
session, error = await _validate_run_authorization(
'run_reject_model_sim',
'model',
'model_999',
mock_ap,
caller_plugin_identity='test/runner',
)
# Should reject
assert session is None
@@ -1641,7 +1672,13 @@ class TestStoragePermissionValidation:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization('run_plugin_storage_auth', 'storage', 'plugin', mock_ap)
session, error = await _validate_run_authorization(
'run_plugin_storage_auth',
'storage',
'plugin',
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is not None
assert error is None
@@ -1670,7 +1707,13 @@ class TestStoragePermissionValidation:
mock_ap.logger = MagicMock()
mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization('run_plugin_storage_denied', 'storage', 'plugin', mock_ap)
session, error = await _validate_run_authorization(
'run_plugin_storage_denied',
'storage',
'plugin',
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is None
assert error is not None
@@ -1700,7 +1743,11 @@ class TestStoragePermissionValidation:
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization(
'run_workspace_storage_auth', 'storage', 'workspace', mock_ap
'run_workspace_storage_auth',
'storage',
'workspace',
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is not None
@@ -1731,7 +1778,11 @@ class TestStoragePermissionValidation:
mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization(
'run_workspace_storage_denied', 'storage', 'workspace', mock_ap
'run_workspace_storage_denied',
'storage',
'workspace',
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is None
@@ -1769,7 +1820,13 @@ class TestFilePermissionValidation:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization('run_file_auth', 'file', 'file_001', mock_ap)
session, error = await _validate_run_authorization(
'run_file_auth',
'file',
'file_001',
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is not None
assert error is None
@@ -1803,6 +1860,7 @@ class TestFilePermissionValidation:
'file',
'file_999', # Not in resources
mock_ap,
caller_plugin_identity='test/runner',
)
assert session is None
@@ -1891,9 +1949,8 @@ class TestCallerPluginIdentityValidation:
await registry.unregister('run_identity_mismatch')
@pytest.mark.asyncio
async def test_no_caller_identity_allowed(self):
"""_validate_run_authorization allows when caller_plugin_identity not provided."""
# Unscoped plugin path: if caller_plugin_identity is None, skip identity check
async def test_run_id_requires_caller_identity(self):
"""Run-scoped authorization requires caller_plugin_identity."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
@@ -1912,18 +1969,17 @@ class TestCallerPluginIdentityValidation:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
# caller_plugin_identity not provided (None)
session, error = await _validate_run_authorization(
'run_no_caller_identity',
'model',
'model_001',
mock_ap,
caller_plugin_identity=None, # Not provided
caller_plugin_identity=None,
)
# Should pass (backward compat)
assert session is not None
assert error is None
assert session is None
assert error is not None
assert 'caller_plugin_identity is required' in error.message
await registry.unregister('run_no_caller_identity')

View File

@@ -54,7 +54,8 @@ def test_classify_python_workspace_detects_package_and_requirements():
def test_wrap_python_command_with_env_contains_bootstrap_and_command():
command = wrap_python_command_with_env('python script.py')
assert 'python -m venv "$_LB_VENV_DIR"' in command
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
assert command.rstrip().endswith('python script.py')

View File

@@ -494,6 +494,47 @@ class TestBuildBoxProcessPayload:
assert payload['args'] == ['/opt/other/server.py', '--flag']
# ── Python Workspace Preparation ────────────────────────────────────
class TestPythonWorkspacePreparation:
def test_requirements_workspace_uses_venv_bootstrap(self, mcp_module, tmp_path):
host_path = tmp_path / 'mcp-source'
host_path.mkdir()
(host_path / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8')
command = mcp_module.BoxStdioSessionRuntime.detect_install_command(
str(host_path),
'/workspace/.mcp/u1/workspace',
)
assert command is not None
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
assert 'python -m pip install -r "/workspace/.mcp/u1/workspace/requirements.txt"' in command
assert 'pip install --no-cache-dir -r' not in command
def test_process_payload_can_start_from_prepared_python_env(self, mcp_module):
payload = {
'command': 'python',
'args': ['/workspace/.mcp/u1/workspace/server.py'],
'env': {},
'cwd': '/workspace/.mcp/u1/workspace',
}
wrapped = mcp_module.BoxStdioSessionRuntime._wrap_process_payload_with_python_env(
payload,
'/workspace/.mcp/u1/workspace',
)
assert wrapped['command'] == 'sh'
assert wrapped['args'][0] == '-lc'
assert 'export VIRTUAL_ENV=/workspace/.mcp/u1/workspace/.venv' in wrapped['args'][1]
assert 'export PATH=/workspace/.mcp/u1/workspace/.venv/bin:$PATH' in wrapped['args'][1]
assert 'exec python /workspace/.mcp/u1/workspace/server.py' in wrapped['args'][1]
assert wrapped['cwd'] == '/workspace/.mcp/u1/workspace'
# ── get_runtime_info_dict ───────────────────────────────────────────

View File

@@ -212,7 +212,8 @@ class TestSkillPathHelpers:
command = wrap_skill_command_with_python_env('python scripts/run.py')
assert 'python -m venv "$_LB_VENV_DIR"' in command
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
assert command.rstrip().endswith('python scripts/run.py')

View File

@@ -6,7 +6,6 @@ import {
PipelineConfigStage,
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
@@ -237,7 +236,7 @@ export default function PipelineFormComponent({
initializedStagesRef.current.clear();
});
}
}, []);
}, [form, isEditMode, pipelineId]);
useEffect(() => {
if (!isEditMode) {
@@ -310,7 +309,7 @@ export default function PipelineFormComponent({
});
}
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// Called from DynamicFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
// However, if the form is already dirty (the user has made real changes),
@@ -325,8 +324,7 @@ export default function PipelineFormComponent({
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
(form.getValues(formName) as Record<string, unknown>) || {};
form.setValue(formName, {
...currentValues,
[stageName]: values,
@@ -351,10 +349,8 @@ export default function PipelineFormComponent({
) {
// Special handling for AI config section
if (formName === 'ai') {
// Get the currently selected runner (use 'id' for new format, fallback to 'runner' for old)
const runnerConfig = (form.watch('ai.runner') as any) || {};
const currentRunner = runnerConfig.id || runnerConfig.runner;
const currentRunner = runnerConfig.id;
// If this is the runner selector stage, render it directly
if (stage.name === 'runner') {
@@ -372,9 +368,9 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
(form.watch(formName) as Record<string, unknown>)?.[
stage.name
] || {}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
@@ -390,36 +386,6 @@ export default function PipelineFormComponent({
return null;
}
// For old n8n built-in runner config, use N8nAuthFormComponent for form linkage
// New plugin:langbot/n8n-agent/default follows normal plugin runner path below
if (stage.name === 'n8n-service-api') {
return (
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<N8nAuthFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</CardContent>
</Card>
);
}
// For plugin runner configs, store in ai.runner_config[runnerId]
const isPluginRunner =
@@ -469,7 +435,7 @@ export default function PipelineFormComponent({
// hard-coding a banner. Field-level gating keeps unrelated fields
// untouched.
const stageSystemContext =
stage.name === 'local-agent'
stage.name === 'plugin:langbot/local-agent/default'
? { box_available: boxAvailable }
: undefined;
@@ -487,8 +453,8 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
(form.watch(formName) as Record<string, unknown>)?.[stage.name] ||
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);

View File

@@ -86,7 +86,6 @@ export default function WizardPage() {
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [botDescription, _setBotDescription] = useState('');
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
{},
@@ -202,9 +201,7 @@ export default function WizardPage() {
const runnerOptions = useMemo(() => {
if (!runnerStage) return [];
const runnerField =
runnerStage.config.find((c) => c.name === 'id') ??
runnerStage.config.find((c) => c.name === 'runner');
const runnerField = runnerStage.config.find((c) => c.name === 'id');
return runnerField?.options ?? [];
}, [runnerStage]);
@@ -1138,8 +1135,7 @@ function StepAIEngine({
})}
{/* Space promotion banner */}
{(selected === 'local-agent' ||
selected === 'plugin:langbot/local-agent/default') &&
{selected === 'plugin:langbot/local-agent/default' &&
isLocalAccount && (
<div className="animate-in fade-in slide-in-from-left-2 duration-300">
<div className="relative rounded-lg p-[2px] bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500">