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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ class Settings(BaseSettings):
codex_a2a_workspace_root: str = ""
codex_a2a_timeout_seconds: float = 1800.0
codex_a2a_token: str = ""
# 飞书官方 lark-cli 集成(app/lark_cli/)。开启即进入能力清单;应用凭据
# 与用户登录都在对话中完成(config init / auth login 设备码),无需预置
# 配置或飞书渠道绑定。
lark_cli_enabled: bool = True
# 二进制供给见 lark_cli/provision.py:懒加载(首次调用时装,非启动时),
# 装到用户数据目录。关掉自动安装则必须自备二进制并指定 binary_path。
lark_cli_auto_install: bool = True
lark_cli_binary_path: str = ""
tool_base_url: str = "http://localhost:5173"
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
general_skill_runtime_python: str = ""
Expand Down
35 changes: 35 additions & 0 deletions backend/app/core/capability_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"exec_command",
"run_skill_script",
"knowledge_search",
"lark_cli",
}


Expand All @@ -68,6 +69,12 @@ def build(
unavailable: list[CapabilityDescriptor] = []

available.extend(_internal_capability_descriptors())
lark_descriptor = _lark_cli_descriptor(self.db, tenant_id, agent_id)
if lark_descriptor is not None:
if lark_descriptor.available:
available.append(lark_descriptor)
else:
unavailable.append(lark_descriptor)
ui_config = self.db.get(UIConfig, tenant_id)
sandbox_enabled = bool(getattr(ui_config, "sandbox_enabled", False))

Expand Down Expand Up @@ -353,6 +360,34 @@ def _unavailable_explicit_refs(
return unavailable


def _lark_cli_descriptor(
db: Session, tenant_id: str, agent_id: str | None
) -> CapabilityDescriptor | None:
"""settings 未开启时返回 None(清单完全不出现,保持既有行为)。

开启即视为可用:应用凭据除 settings / 渠道绑定外,还可在对话内通过
``config init`` 现场建立(含 ``--new`` 创建新应用),无法在编译清单时
预判缺失,缺凭据的具体指引由 service 层以可恢复错误给出。
"""

from app.config import get_settings
from app.lark_cli.service import LARK_CLI_DESCRIPTION, LARK_CLI_INPUT_SCHEMA

del db, tenant_id, agent_id # 凭据可对话内建立后不再需要预检数据库。
settings = get_settings()
if not settings.lark_cli_enabled:
return None
return CapabilityDescriptor(
capability_id="builtin.lark_cli",
name="lark_cli",
kind="internal",
description=LARK_CLI_DESCRIPTION,
input_schema=dict(LARK_CLI_INPUT_SCHEMA),
metadata={"provider": "builtin.lark_cli", "side_effect": "write"},
available=True,
)


def _internal_capability_descriptors() -> list[CapabilityDescriptor]:
return [
CapabilityDescriptor(
Expand Down
76 changes: 76 additions & 0 deletions backend/app/core/harness_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ def run(
and str(checkpoint.get("step_id") or "") == current_step_id
)
transcript = _dict_items(checkpoint.get("transcript")) if same_frame else []
# 从暂停恢复时,把用户的最新回复显式写进内层对话记录:仅靠
# requirement.source_user_message 字段太不显眼,实测模型会忽略它
# 而重复上一轮的提问(真实案例:确认暂停点反复索要确认)。
latest_user_message = str(requirement.source_user_message or "").strip()
if same_frame and transcript and latest_user_message:
previous_user_entry = next(
(
str(item.get("content") or "")
for item in reversed(transcript)
if item.get("role") == "user"
),
None,
)
if previous_user_entry != latest_user_message:
transcript.append({"role": "user", "content": latest_user_message})
citations = _dict_items(checkpoint.get("citations")) if same_frame else []
evidence_results = (
_dict_items(checkpoint.get("evidence_results")) if same_frame else []
Expand Down Expand Up @@ -110,6 +125,8 @@ def run(
# checkpoint made a later user turn inherit an obsolete failure even
# after its inputs or external state had changed.
non_retryable_action_signatures: set[str] = set()
# 强制能力从未尝试就 finish(failed) 只拦一次,避免与固执模型互相死锁。
failed_finish_without_attempt_blocked = False
allowed_names = requirement.capability_manifest.allowed_names()
system_prompt = PROMPT_PATH.read_text(encoding="utf-8").strip()
pending_actions: list[HarnessAction] = []
Expand Down Expand Up @@ -369,6 +386,65 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult:
},
)
continue
# 真实案例:提交节点的模型带着上一步骤的工具报错直接
# finish(failed),全程没在本节点尝试过强制能力。失败结论
# 必须建立在真实尝试之上,先打回一次要求实际调用。
if (
action.status == "failed"
and missing_capabilities
and not failed_finish_without_attempt_blocked
):
attempted = {
str(item.get("tool_name") or "")
for item in capability_results
if isinstance(item, dict)
}
unattempted = [
name
for name in missing_capabilities
if not name.startswith("knowledge_search:")
and name not in attempted
]
if unattempted:
failed_finish_without_attempt_blocked = True
transcript.extend(
[
{
"role": "assistant",
"action": "finish",
"status": "failed",
},
{
"role": "tool",
"tool_name": "harness_requirement_check",
"result": {
"success": False,
"error": {
"code": (
"REQUIRED_CAPABILITY_NOT_ATTEMPTED"
),
"message": (
"当前 SOP 节点的强制能力在本节点内"
"尚未尝试调用,不能直接宣告失败:"
+ "、".join(unattempted)
+ "。请先实际调用,再依据其真实"
"结果决定完成或失败。"
),
},
},
},
]
)
if trace_sink:
trace_sink(
"harness_completion_blocked",
{
"iteration": iteration,
"reason": "required_capability_not_attempted",
"missing_capabilities": unattempted,
},
)
continue
return finish(_finish_result(
requirement,
action,
Expand Down
49 changes: 48 additions & 1 deletion backend/app/core/harness_capability_invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
register_skill_script_tools,
snapshot_harness_workspace,
)
from app.harness.execution_context import SANDBOX_WORKSPACE
from app.harness.errors import HarnessExecutionError
from app.harness.execution_context import SANDBOX_WORKSPACE
from app.harness.sandbox import parse_network_policy
from app.knowledge.citations import knowledge_citations_from_results
from app.knowledge.schema import KnowledgeSearchRequest
Expand Down Expand Up @@ -318,6 +318,25 @@ def _logical_action_key(
descriptor: CapabilityDescriptor,
arguments: dict[str, Any],
) -> str | None:
if descriptor.kind == "internal" and descriptor.name == "lark_cli":
from app.lark_cli.policy import logical_write_signature

signature = logical_write_signature(arguments)
if signature is None:
return None
canonical = json.dumps(
{
"tenant_id": self.tenant_id,
"task_frame_id": self.task_frame_id,
"step_id": self.active_step_id,
"tool_id": "builtin.lark_cli",
"signature": signature,
},
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
if descriptor.kind != "tool":
return None
tool = self.db.get(Tool, descriptor.capability_id)
Expand Down Expand Up @@ -503,6 +522,19 @@ def _invoke_internal(
return self._list_published_deliverables(arguments)
if name == "read_published_deliverable":
return self._read_published_deliverable(arguments)
if name == "lark_cli":
from app.lark_cli.service import invoke_lark_cli

return invoke_lark_cli(
self.db,
tenant_id=self.tenant_id,
session=self.session,
task_frame_id=self.task_frame_id,
agent_id=self.agent_id,
arguments=arguments,
active_skill=self.active_skill,
active_step_id=self.active_step_id,
)
return _failure(
"UNSUPPORTED_INTERNAL_CAPABILITY",
"不支持的 Harness 内部能力。",
Expand Down Expand Up @@ -1512,11 +1544,26 @@ def _audit_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
for token in ("content", "secret", "token", "password", "api_key")
):
audited[str(key)] = "<redacted>"
elif isinstance(value, list):
audited[str(key)] = _redact_secret_flag_values(value)
else:
audited[str(key)] = value
return audited


def _redact_secret_flag_values(items: list[Any]) -> list[Any]:
"""argv 风格列表里跟在敏感 flag(如 --app-secret)后的值不落审计记录。"""

redacted = list(items)
for index, token in enumerate(redacted[:-1]):
if not isinstance(token, str) or not token.startswith("-"):
continue
lowered = token.lower()
if any(part in lowered for part in ("secret", "token", "password")):
redacted[index + 1] = "<redacted>"
return redacted


def _audit_result(result: dict[str, Any]) -> dict[str, Any]:
audited = dict(result)
data = audited.get("data")
Expand Down
18 changes: 14 additions & 4 deletions backend/app/core/harness_v2_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,12 +848,15 @@ def _run_frame(
],
attachment_descriptors,
published_deliverables,
# 驱动本次执行的是当前这条用户消息。长驻 SOP 帧跨回合恢复
# 时若改用创建该帧的老消息,会顶掉用户的最新回复(真实案例:
# 「提交」被首句需求顶掉,恢复的 agent 看不到而重复提问)。
# 原始意图仍在 user_intent 与 slots 里,不丢。
source_user_message=(
request.message
if row.source_turn_id == self.user_message_id
else _source_user_message(self.db, row)
request.message.strip() or _source_user_message(self.db, row)
),
out_of_scope_task_intents=_sibling_task_intents(self.db, row),
client_timezone=request.client_timezone,
)
if (
self.slash_command
Expand Down Expand Up @@ -1542,10 +1545,17 @@ def _defer_failed_step_after_completed_checkpoint(
for summary in (checkpoint.task_summary.strip(), failure_summary)
if summary
]
# 上一节点的回复可能带有"接下来将…"式的前瞻表述;后续步骤实际已
# 暂停排队、要等下一条用户消息才继续,必须向用户说清(真实案例:
# 用户看到"将进入正式提交步骤"以为会自动提交,实际什么都没发生)。
reply = checkpoint.reply_fragment.rstrip()
suffix = "(本轮执行到此暂停,剩余步骤已排队;回复任意消息即可继续。)"
if suffix not in reply:
reply = f"{reply}\n\n{suffix}"
return result.model_copy(
update={
"status": "action_budget",
"reply_fragment": checkpoint.reply_fragment,
"reply_fragment": reply,
"next_step_id": None,
"task_summary": ";".join(dict.fromkeys(summaries)),
}
Expand Down
37 changes: 35 additions & 2 deletions backend/app/core/task_request_compiler.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from datetime import datetime
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from pydantic import BaseModel, Field

from app.db.models import ChatSession, Skill
from app.session.session_schema import PlannedTaskFrame


CapabilityKind = Literal[
"general_skill",
"knowledge",
Expand Down Expand Up @@ -56,6 +57,11 @@ class TaskRequirement(BaseModel):
task_frame_id: str
kind: Literal["sop", "conversation"]
goal: str
# 当前时间(用户时区,含偏移与星期),供模型换算"明天/下周一"等相对
# 日期。模型无法自知"今天几号",若不注入则依赖它主动去查;真实案例中
# 它改为让 shell 代算(`date -d tomorrow; date`),GNU 参数在 macOS 上
# 报错但被 `;` 吞掉退出码,后一条命令补上今天的日期,静默给出错答案。
current_time: str = ""
source_user_message: str = ""
out_of_scope_task_intents: list[str] = Field(default_factory=list)
requirements: list[str] = Field(default_factory=list)
Expand Down Expand Up @@ -112,6 +118,7 @@ def compile(
published_deliverables: list[dict[str, Any]] | None = None,
source_user_message: str | None = None,
out_of_scope_task_intents: list[str] | None = None,
client_timezone: str | None = None,
) -> TaskRequirement:
current_node = _current_node(skill, frame.target_step_id or session.active_step_id)
expected_fields = _text_list((current_node or {}).get("expected_user_info"))
Expand Down Expand Up @@ -169,6 +176,7 @@ def compile(
task_frame_id=str(frame.task_id or ""),
kind=frame.kind,
goal=goal,
current_time=_current_time_text(client_timezone),
source_user_message=str(source_user_message or "").strip()[:4_000],
out_of_scope_task_intents=_unique(
[str(item or "") for item in out_of_scope_task_intents or []]
Expand All @@ -189,6 +197,25 @@ def compile(
)


def _current_time_text(client_timezone: str | None = None) -> str:
"""当前时间文本;优先用户所在时区,缺省或非法时回退服务端本地时区。

注入错误时区的时间比不注入更危险(看起来权威,模型不会质疑),
因此时区来源必须显式,且结果始终带 UTC 偏移。
"""

zone: ZoneInfo | None = None
name = str(client_timezone or "").strip()
if name:
try:
zone = ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
zone = None
now = datetime.now(zone) if zone is not None else datetime.now().astimezone()
weekday = "一二三四五六日"[now.weekday()]
return f"{now.isoformat(timespec='minutes')}(周{weekday})"


def current_step_capability_refs(skill: Skill | None, step_id: str | None) -> dict[str, list[str]]:
node = _current_node(skill, step_id)
refs = (node or {}).get("capability_refs")
Expand Down Expand Up @@ -252,7 +279,13 @@ def _required_step_capabilities(
descriptor.capability_id in required_skill_refs
or descriptor.name in required_skill_refs
)
if matches_tool or matches_skill:
# 内置能力(如 lark_cli)也允许被 SOP 节点标记为强制执行,
# 使 finish 闸(REQUIRED_CAPABILITY_NOT_INVOKED)同样生效。
matches_internal = descriptor.kind == "internal" and (
descriptor.capability_id in required_tool_refs
or descriptor.name in required_tool_refs
)
if matches_tool or matches_skill or matches_internal:
required.append(descriptor.name)
if required_knowledge_base_ids:
required.append("knowledge_search")
Expand Down
Loading