Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox. Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run. Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables. Refs: .task/feature-layered-python-env.md Co-authored-by: Cursor <cursoragent@cursor.com>
125 lines
4.4 KiB
Go
125 lines
4.4 KiB
Go
package tools
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"agent-system-backend/internal/execenv"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// envBroker 由组合根注入。未注入时 python_env 明确报错,不假装能 pip install。
|
||
var envBroker *execenv.Broker
|
||
|
||
// envApproval 在申请需要人批时写一张选择卡。空则只把 pending 文本回给模型。
|
||
var envApproval func(owner, conversationID, requestID, detail string) error
|
||
|
||
// SetEnvBroker 注入分层环境代理。
|
||
func SetEnvBroker(b *execenv.Broker) { envBroker = b }
|
||
|
||
// SetEnvApproval 注入审批卡写入。
|
||
func SetEnvApproval(fn func(owner, conversationID, requestID, detail string) error) {
|
||
envApproval = fn
|
||
}
|
||
|
||
func registerPythonEnvTool(r *Registry) error {
|
||
return r.Register(core.ToolDesc{
|
||
Name: "python_env", Source: core.SourceBuiltin, Risk: "medium",
|
||
Description: "向系统申请 Python 包,或查看本会话已经能用的环境。沙箱不能联网安装。action=status 查看基础层与本会话的层;action=request 申请包(packages)或登记工作区内的 wheel;白名单内的包会自动装好并在下一次 run_python 生效,其余会进入审批。action=wait 等待 requestId 结束。不要用 pip install。",
|
||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||
`"action":{"type":"string","enum":["status","request","wait"],"description":"status | request | wait"},` +
|
||
`"packages":{"type":"array","items":{"type":"string"},"description":"包规格,如 pdfplumber 或 numpy==2.2.0"},` +
|
||
`"reason":{"type":"string","description":"为什么需要这些包"},` +
|
||
`"wheel":{"type":"string","description":"工作区内的 .whl 相对路径,上传的包需要审批"},` +
|
||
`"request_id":{"type":"string","description":"wait 时的申请 ID"},` +
|
||
`"timeout_sec":{"type":"integer","description":"wait 的秒数,默认 120"}}` +
|
||
`,"required":["action"]}`),
|
||
}, execPythonEnv)
|
||
}
|
||
|
||
func execPythonEnv(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||
if err := execGate(rc); err != nil {
|
||
return "", err
|
||
}
|
||
if envBroker == nil {
|
||
return "", fmt.Errorf("python_env: 环境代理未配置(启动参数 execEnvRoot)")
|
||
}
|
||
var a struct {
|
||
Action string `json:"action"`
|
||
Packages []string `json:"packages"`
|
||
Reason string `json:"reason"`
|
||
Wheel string `json:"wheel"`
|
||
RequestID string `json:"request_id"`
|
||
TimeoutS int `json:"timeout_sec"`
|
||
}
|
||
if len(args) > 0 {
|
||
if err := json.Unmarshal(args, &a); err != nil {
|
||
return "", fmt.Errorf("python_env: bad args: %w", err)
|
||
}
|
||
}
|
||
switch a.Action {
|
||
case "status":
|
||
return envBroker.StatusText(rc.ConversationID())
|
||
case "wait":
|
||
if strings.TrimSpace(a.RequestID) == "" {
|
||
return "", fmt.Errorf("python_env: request_id required")
|
||
}
|
||
timeout := time.Duration(a.TimeoutS) * time.Second
|
||
out, err := envBroker.Wait(execParentCtx(rc), a.RequestID, timeout)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
raw, _ := json.Marshal(out)
|
||
return string(raw), nil
|
||
case "request":
|
||
wheel := ""
|
||
if strings.TrimSpace(a.Wheel) != "" {
|
||
sandbox, err := execSandbox(rc)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
wheel, err = filepath.Abs(filepath.Join(sandbox, a.Wheel))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !strings.HasPrefix(wheel, sandbox+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("python_env: wheel must stay inside the workspace")
|
||
}
|
||
}
|
||
out, err := envBroker.RequestPackages(execParentCtx(rc), rc.ConversationID(), rc.OwnerID(), a.Reason, a.Packages, wheel)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if out.Status == "pending_approval" && envApproval != nil {
|
||
if err := envApproval(rc.OwnerID(), rc.ConversationID(), out.RequestID, out.Detail); err != nil {
|
||
out.Detail += ";审批卡未写入:" + err.Error()
|
||
}
|
||
}
|
||
raw, _ := json.Marshal(out)
|
||
return string(raw), nil
|
||
default:
|
||
return "", fmt.Errorf("python_env: unknown action %q", a.Action)
|
||
}
|
||
}
|
||
|
||
// ExecDoctor 用当前执行后端探针导入基础交付物库。依赖层已配置但导入失败时返回错误。
|
||
func ExecDoctor() error {
|
||
bin, err := resolveInterpreter("python3")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
dirs, _ := pythonPathFor("")
|
||
mounts := append([]string{}, dirs...)
|
||
if execPythonPrefix != "" {
|
||
mounts = append(mounts, execPythonPrefix)
|
||
}
|
||
if execShimDir != "" {
|
||
mounts = append(mounts, execShimDir)
|
||
}
|
||
return execenv.Doctor(context.Background(), bin, dirs, nil, ExecBackendName() == "sandbox", mounts)
|
||
}
|