Skip to content
Closed
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
53 changes: 53 additions & 0 deletions loopx/capabilities/manager_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,56 @@ Chat receipt uniquely recovers the route. Historical timestamps stay unknown.
Replies are immutable and additive, separate from private decision reasons and
Core progress. Query `manager-inbox status` or `loopx_manager_read view=handoffs`
for delivery diagnostics. These queries are not required from the user.

### Repository artifact evidence / 仓库产物证据

In managed manager Chat, `loopx_manager_read view=repository_artifact` binds an
explicit pull-request reference to a credential-free repository identity already
declared by the authorized Goal or one of its Core Todos. It reads the GitHub
artifact through fixed, read-only semantic operations. Start with `overview`,
then pass the returned `head_sha` as `expected_head_sha` while paginating only
the needed `files`, `diff`, `reviews`, `issue_comments`, `review_comments`,
`checks`, or `source_file`. Source files accept only repository-relative paths
and are pinned to the current PR head or base commit. A head change stops the
read instead of mixing revisions.

The provider never grants shell, arbitrary CLI arguments, writes, or local file
access. Permission, credentials, network, rate-limit, not-found, head-change and
truncation outcomes remain distinct typed evidence. A read failure does not
automatically create work or hand off the question. For an explicit
implementation, execution-validation or extended-investigation request, the
typed gap may recommend one authorized receiver only when its validated
`agent_profile_v1.preferred_action_kinds` matches `repository_evidence` and a
current Core Todo binds that Agent to the same repository. No match and multiple
matches remain explicit routing gaps; a sole visible Agent or list position is
never an implicit receiver.

The managed Turn, web frontend and Lark render the same Chat tool result. The
local CLI exposes the same projection, for example:

```text
loopx goal-portfolio --manager-view repository_artifact --goal-id <goal> \
--repository-id git:github.com/<owner>/<repo> --artifact-ref '#42'
```

No separate UI configuration or state owner is introduced. `manager-inbox`
remains the CLI readback path for an actual handoff and its delivery diagnostics.

在托管管家对话中,`loopx_manager_read view=repository_artifact` 会把明确的
PR 引用绑定到已授权 Goal 或其 Core Todo 声明的无凭据仓库身份,并通过固定、
只读的语义操作读取 GitHub 原件。先读 `overview`,再把返回的 `head_sha` 作为
`expected_head_sha`,按需分页读取 `files`、`diff`、`reviews`、两类评论、检查
或 `source_file`。源码只接受仓库相对路径,并固定到当前 PR 的 head 或 base
commit;head 变化会中止读取,禁止混用不同版本证据。

provider 不授予 shell、任意 CLI 参数、写权限或本地文件访问;权限、凭据、
网络、限流、未找到、head 变化和截断分别返回类型化证据。读取失败不会自动
创建任务或交接。只有用户明确要求实施、执行验证或较长调查,并且某个已授权
接收方的 `agent_profile_v1.preferred_action_kinds` 与
`repository_evidence` 匹配,并且当前 Core Todo 把该 Agent 绑定到同一仓库时,
才可推荐该接收方。无匹配或多匹配都会保留为明确缺口,禁止按唯一可见 Agent
或列表位置猜测。

托管 Turn、Web 前端和 Lark 渲染同一份 Chat 工具结果;本地 CLI 暴露同一投影,
不新增 UI 配置或状态源。真正发生交接时,仍复用既有收件箱、不可变结论和
exactly-once 回执,`manager-inbox` 继续作为接收方与交付诊断的 CLI 回读入口。
19 changes: 18 additions & 1 deletion loopx/capabilities/manager_context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import tempfile
from typing import Any

from ...agent_registry import registered_agent_ids_for_goal
from ...agent_registry import agent_profile_for_goal, registered_agent_ids_for_goal
from ...control_plane.agents.profile import normalize_agent_profile
from ...file_lock import exclusive_file_lock
from ...history import load_registry

Expand Down Expand Up @@ -141,9 +142,25 @@ def authority(
targets = [
{"goal_id": g, "agent_id": a} for g, a in sorted(allowed & set(available))
]
routing_profiles = []
for target in targets:
goal = available[(target["goal_id"], target["agent_id"])]
raw_profile = agent_profile_for_goal(goal, target["agent_id"])
if raw_profile is None:
continue
try:
profile = normalize_agent_profile(
raw_profile,
registered_agents=registered_agent_ids_for_goal(goal),
expected_agent_id=target["agent_id"],
)
except ValueError:
continue
routing_profiles.append({"goal_id": target["goal_id"], **profile})
return {
"mode": "context_only",
"targets": targets,
"routing_profiles": routing_profiles,
"source_id": source_id,
"instruction": INSTRUCTION,
}
Expand Down
19 changes: 19 additions & 0 deletions loopx/capabilities/manager_context/evidence_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pathlib import Path


def export_page(registry_path, runtime_root_arg, args):

Check failure on line 7 in loopx/capabilities/manager_context/evidence_export.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaCZVM29Qzi-Va3r3D2W&open=AaCZVM29Qzi-Va3r3D2W&pullRequest=4306
from ...paths import resolve_runtime_root
from ...chat_manager_context import manager_turn_context
from .inspection import ManagerInspection, TOOL_NAME
Expand Down Expand Up @@ -41,6 +41,25 @@
query["goal_id"] = ids[0]
if args.manager_view == "deliveries":
query["days"] = args.days
if args.manager_view == "repository_artifact":
query.update(
artifact_ref=getattr(args, "artifact_ref", None),
artifact_section=getattr(args, "artifact_section", "overview"),
)
for argument, key in (
("repository_id", "repository_id"),
("expected_head_sha", "expected_head_sha"),
("source_path", "source_path"),
):
value = getattr(args, argument, None)
if value:
query[key] = value
if query["artifact_section"] == "source_file":
query.update(
source_ref=getattr(args, "source_ref", "head"),
source_line_start=getattr(args, "source_line_start", 1),
source_line_limit=getattr(args, "source_line_limit", 120),
)
result = inspector.read(TOOL_NAME, query)
for row in result.get("rows", []):
row.setdefault("goal_id", query.get("goal_id"))
Expand Down
125 changes: 118 additions & 7 deletions loopx/capabilities/manager_context/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import json
from collections.abc import Callable
from pathlib import Path
import re
from typing import Any

from ...chat_manager_details import read_manager_goal_details
from ...chat_manager_history import read_manager_delivery_history
from .repository_evidence_github import RepositoryEvidenceReader


TOOL_NAME = "loopx_manager_read"
Expand All @@ -17,17 +19,17 @@
"name": TOOL_NAME,
"description": (
"Read authorized LoopX Core evidence on demand: the global Goal portfolio, "
"one Goal's current Todos, recorded deliveries, or handoff receipt status. Use concrete evidence "
"one Goal's current Todos, recorded deliveries, revision-pinned repository artifacts, or handoff receipt status. Use concrete evidence "
"to answer progress and priority questions. Paginate with next_offset. "
"No shell, writes, raw files, or additional Goal authorization."
"No shell, writes, local files, or additional Goal authorization."
),
"inputSchema": {
"type": "object",
"additionalProperties": False,
"properties": {
"view": {
"type": "string",
"enum": ["sources", "portfolio", "todos", "deliveries", "handoffs"],
"enum": ["sources", "portfolio", "todos", "deliveries", "repository_artifact", "handoffs"],
},
"source_id": {"type": "string", "description": "Default local. For SSH use an exact source_id from view=sources; local Goal IDs do not discover remote Goals."},
"days": {"type": "integer", "minimum": 1, "maximum": 90, "description": "Deliveries lookback; expand for latest known progress older than yesterday."},
Expand All @@ -37,6 +39,34 @@
"pattern": "^[a-f0-9]{64}$",
"description": "Handoffs only: exact request receipt ID.",
},
"repository_id": {
"type": "string",
"description": "Repository-artifact only: exact credential-free git:<host>/<owner>/<repo> identity. Omit once to discover available identities for a short PR reference.",
},
"artifact_ref": {
"type": "string",
"description": "Repository-artifact only: #NUMBER, NUMBER, or an exact HTTPS pull-request URL.",
},
"artifact_section": {
"type": "string",
"enum": ["overview", "files", "diff", "reviews", "issue_comments", "review_comments", "checks", "source_file"],
"description": "Repository-artifact only. Read overview first, then pass its exact head SHA for deeper sections.",
},
"expected_head_sha": {
"type": "string",
"pattern": "^[0-9a-f]{40}$",
"description": "Repository-artifact follow-up only: exact head SHA returned by overview, preventing mixed-revision evidence.",
},
"source_path": {
"type": "string", "maxLength": 500,
"description": "Source-file only: repository-relative path at the PR head or base revision.",
},
"source_ref": {
"type": "string", "enum": ["head", "base"],
"description": "Source-file only: select the current PR head or base commit.",
},
"source_line_start": {"type": "integer", "minimum": 1, "maximum": 1000000},
"source_line_limit": {"type": "integer", "minimum": 1, "maximum": 200},
"include_stopped": {
"type": "boolean",
"description": "Portfolio only: include stopped Goals for an explicit historical question.",
Expand All @@ -48,6 +78,46 @@
},
}

_REPOSITORY_SECTIONS = {
"overview", "files", "diff", "reviews", "issue_comments",
"review_comments", "checks", "source_file",
}


def _repository_arguments_valid(arguments: dict[str, Any]) -> bool:
artifact_ref = arguments.get("artifact_ref")
section = arguments.get("artifact_section", "overview")
source_ref = arguments.get("source_ref", "head")
expected_head = arguments.get("expected_head_sha")
source_path = arguments.get("source_path")
line_start = arguments.get("source_line_start", 1)
line_limit = arguments.get("source_line_limit", 120)
offset = arguments.get("offset", 0)
if (
not isinstance(artifact_ref, str) or not artifact_ref.strip()
or len(artifact_ref) > 500
or ("repository_id" in arguments and not isinstance(arguments.get("repository_id"), str))
or not isinstance(section, str) or section not in _REPOSITORY_SECTIONS
or not isinstance(source_ref, str) or source_ref not in {"head", "base"}
or type(line_start) is not int or not 1 <= line_start <= 1_000_000
or type(line_limit) is not int or not 1 <= line_limit <= 200
or type(offset) is not int or not 0 <= offset <= 10_000
):
return False
if "expected_head_sha" in arguments and (
not isinstance(expected_head, str)
or re.fullmatch(r"[0-9a-f]{40}", expected_head) is None
):
return False
if "source_path" in arguments and (
not isinstance(source_path, str) or not source_path or len(source_path) > 500
):
return False
source_only = {"source_path", "source_ref", "source_line_start", "source_line_limit"}
if section == "source_file":
return "source_path" in arguments
return not any(key in arguments for key in source_only)


def manager_index(context: dict[str, Any]) -> dict[str, Any]:
"""A small directory, never a second mutable progress store."""
Expand Down Expand Up @@ -91,8 +161,9 @@ def __init__(
scope_valid: Callable[[], bool],
record: Callable[[dict[str, Any]], None],
channel_id: str | None = None,
remote_runner=None,
ssh_config_path=None,
remote_runner: Any = None,
ssh_config_path: Path | None = None,
repository_reader: RepositoryEvidenceReader | None = None,
) -> None:
self.context = context
self.registry_path = registry_path
Expand All @@ -103,6 +174,7 @@ def __init__(
self.channel_id = channel_id
self.remote_runner = remote_runner
self.ssh_config_path = ssh_config_path
self.repository_reader = repository_reader

def sources(self):
from .ssh_evidence import sources
Expand All @@ -120,14 +192,28 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]:
"request_id",
"source_id",
"days",
"repository_id",
"artifact_ref",
"artifact_section",
"expected_head_sha",
"source_path",
"source_ref",
"source_line_start",
"source_line_limit",
}:
return {"ok": False, "error": "invalid_arguments"}
view, goal_id = arguments.get("view"), arguments.get("goal_id")
offset, limit = arguments.get("offset", 0), arguments.get("limit", 8)
include_stopped = arguments.get("include_stopped", False)
if (
view not in {"sources", "portfolio", "todos", "deliveries", "handoffs"}
view not in {"sources", "portfolio", "todos", "deliveries", "repository_artifact", "handoffs"}
or ("request_id" in arguments and view != "handoffs")
or ("repository_id" in arguments and view != "repository_artifact")
or ("artifact_ref" in arguments and view != "repository_artifact")
or any(key in arguments and view != "repository_artifact" for key in {
"artifact_section", "expected_head_sha", "source_path", "source_ref",
"source_line_start", "source_line_limit",
})
or type(include_stopped) is not bool
or ("include_stopped" in arguments and view != "portfolio")
or type(offset) is not int
Expand All @@ -137,6 +223,7 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]:
or (goal_id is not None and not isinstance(goal_id, str))
or ("days" in arguments and (view != "deliveries" or type(arguments["days"]) is not int or not 1 <= arguments["days"] <= 90))
or not isinstance(arguments.get("source_id", "local"), str)
or (view == "repository_artifact" and not _repository_arguments_valid(arguments))
):
return {"ok": False, "error": "invalid_arguments"}
if not self.scope_valid():
Expand All @@ -152,7 +239,7 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]:
self.record(result)
return result
if source_id != "local":
if not source_id.startswith("ssh:") or view == "handoffs" or (view != "portfolio" and not goal_id):
if not source_id.startswith("ssh:") or view in {"handoffs", "repository_artifact"} or (view != "portfolio" and not goal_id):
return {"ok": False, "error": "invalid_remote_read"}
from .ssh_evidence import read_remote
result = read_remote(self.runtime_root, self.channel_id, self.owner_scope, arguments,
Expand All @@ -167,6 +254,30 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]:
return {"ok": False, "error": "goal_outside_available_scope"}
if not self.scope_valid():
return {"ok": False, "error": "authorization_changed"}
if view == "repository_artifact":
from .repository_evidence import inspect_repository_artifact
assert isinstance(goal_id, str) and goal_id
result = inspect_repository_artifact(
registry_path=self.registry_path,
runtime_root=self.runtime_root,
goal_id=goal_id,
artifact_ref=arguments["artifact_ref"].strip(),
repository_id=(arguments.get("repository_id", "").strip() or None),
context_delegation=self.context.get("context_delegation"),
section=arguments.get("artifact_section", "overview"),
offset=offset,
limit=limit,
expected_head_sha=arguments.get("expected_head_sha"),
source_path=arguments.get("source_path"),
source_ref=arguments.get("source_ref", "head"),
source_line_start=arguments.get("source_line_start", 1),
source_line_limit=arguments.get("source_line_limit", 120),
reader=self.repository_reader,
)
if not self.scope_valid():
return {"ok": False, "error": "authorization_changed"}
self.record(result)
return result
if view == "portfolio":
rows = list(goals.values()) if goal_id is None else [goals[goal_id]]
if goal_id is None and not include_stopped:
Expand Down
Loading