Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

269 lines
7.9 KiB
Go

package computer
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
)
// mcpProtocolVersion is the MCP version advertised at initialize. We do NOT
// declare the `elicitation` client capability: a compliant server then never
// asks us to elicit, which is exactly the behavior that broke when codex (as
// the MCP client) advertised elicitation and forwarded the request.
const mcpProtocolVersion = "2025-06-18"
// ToolResult is the flattened result of an MCP tools/call.
type ToolResult struct {
Text string
Images []ToolImage
StructuredContent map[string]any
Raw json.RawMessage
IsError bool
}
// ToolImage is a base64 image returned by a tool (e.g. take_screenshot).
type ToolImage struct {
Data string
MimeType string
}
// ToolCatalog is the versioned native tool surface reported by cua-driver.
type ToolCatalog struct {
SchemaVersion string `json:"schemaVersion"`
CapabilityVersion string `json:"capabilityVersion"`
Tools []ToolDefinition `json:"tools"`
}
// ToolDefinition preserves the MCP schema and semantic metadata needed for
// generic discovery, policy, and argument forwarding.
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
Annotations ToolAnnotations `json:"annotations"`
Capabilities []string `json:"capabilities"`
Allowed bool `json:"allowed"`
DenialReason string `json:"denialReason"`
}
// ToolAnnotations mirrors the MCP tool hints. These hints describe effects;
// Tutti-owned policy remains the authorization boundary.
type ToolAnnotations struct {
ReadOnly bool `json:"readOnlyHint"`
Destructive bool `json:"destructiveHint"`
Idempotent bool `json:"idempotentHint"`
OpenWorld bool `json:"openWorldHint"`
}
// computerSession owns one cua-driver subprocess. Tool calls are serialized
// because the underlying computer is single-instance.
type computerSession struct {
transport agentruntime.ProcessTransport
command func(context.Context) []string
startMu sync.Mutex
conn agentruntime.ProcessConnection
client *mcpClient
callMu sync.Mutex
inFlight int32
idleMu sync.Mutex
idle *time.Timer
}
func (s *computerSession) start(ctx context.Context, cwd string) error {
s.startMu.Lock()
defer s.startMu.Unlock()
if s.client != nil && !s.client.isClosed() {
return nil
}
s.closeLocked()
resolveCommand := s.command
if resolveCommand == nil {
resolveCommand = func(ctx context.Context) []string {
return resolveComputerMCPCommand(ctx)
}
}
command := resolveCommand(ctx)
conn, err := s.transport.Start(ctx, agentruntime.ProcessSpec{
Provider: "computer",
CWD: cwd,
Command: command,
Env: computerMCPSubprocessEnv(),
})
if err != nil {
return fmt.Errorf("computer MCP failed to start: %w", err)
}
client := newMCPClient(conn)
if _, err := client.call(ctx, "initialize", map[string]any{
"protocolVersion": mcpProtocolVersion,
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "tuttid-computer", "version": "1"},
}); err != nil {
_ = conn.Close()
return fmt.Errorf("computer MCP initialize failed: %w", err)
}
_ = client.notify("notifications/initialized", map[string]any{})
s.conn = conn
s.client = client
return nil
}
func (s *computerSession) beginCall() {
atomic.AddInt32(&s.inFlight, 1)
s.idleMu.Lock()
if s.idle != nil {
s.idle.Stop()
}
s.idleMu.Unlock()
}
func (s *computerSession) endCall(scheduleIdle func()) {
if atomic.AddInt32(&s.inFlight, -1) == 0 && scheduleIdle != nil {
scheduleIdle()
}
}
func (s *computerSession) inFlightCount() int32 {
return atomic.LoadInt32(&s.inFlight)
}
func (s *computerSession) callTool(ctx context.Context, name string, args map[string]any) (ToolResult, error) {
result, err := s.callNativeTool(ctx, name, args)
if err != nil {
return ToolResult{}, err
}
if result.IsError {
return result, toolResultError(result)
}
return result, nil
}
func (s *computerSession) callNativeTool(ctx context.Context, name string, args map[string]any) (ToolResult, error) {
if s.client == nil || s.client.isClosed() {
return ToolResult{}, errors.New("computer session not started")
}
s.callMu.Lock()
defer s.callMu.Unlock()
if s.client == nil || s.client.isClosed() {
return ToolResult{}, errors.New("computer session not started")
}
raw, err := s.client.call(ctx, "tools/call", map[string]any{"name": name, "arguments": args})
if err != nil {
return ToolResult{}, err
}
return parseToolResult(raw)
}
func (s *computerSession) listTools(ctx context.Context) (ToolCatalog, error) {
if s.client == nil || s.client.isClosed() {
return ToolCatalog{}, errors.New("computer session not started")
}
s.callMu.Lock()
defer s.callMu.Unlock()
if s.client == nil || s.client.isClosed() {
return ToolCatalog{}, errors.New("computer session not started")
}
raw, err := s.client.call(ctx, "tools/list", map[string]any{})
if err != nil {
return ToolCatalog{}, err
}
return parseToolCatalog(raw)
}
func (s *computerSession) close() {
s.startMu.Lock()
defer s.startMu.Unlock()
s.closeLocked()
}
func (s *computerSession) closeLocked() {
if s.conn != nil {
_ = s.conn.Close()
}
s.conn = nil
s.client = nil
}
type mcpToolCallResult struct {
IsError bool `json:"isError"`
StructuredContent map[string]any `json:"structuredContent"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Data string `json:"data"`
MimeType string `json:"mimeType"`
} `json:"content"`
}
func parseToolResult(raw json.RawMessage) (ToolResult, error) {
var parsed mcpToolCallResult
if err := json.Unmarshal(raw, &parsed); err != nil {
return ToolResult{}, fmt.Errorf("decode computer tool result: %w", err)
}
result := ToolResult{
IsError: parsed.IsError,
StructuredContent: parsed.StructuredContent,
Raw: append(json.RawMessage(nil), raw...),
}
var texts []string
for _, item := range parsed.Content {
switch item.Type {
case "text":
texts = append(texts, item.Text)
case "image":
result.Images = append(result.Images, ToolImage{Data: item.Data, MimeType: item.MimeType})
}
}
result.Text = strings.Join(texts, "\n")
return result, nil
}
func toolResultError(result ToolResult) error {
return errors.New(ToolResultDiagnostic(result))
}
// ToolResultDiagnostic preserves the native result envelope while giving
// stable aliases and raw native CLI callers the same actionable explanation.
func ToolResultDiagnostic(result ToolResult) string {
message := strings.TrimSpace(result.Text)
if message == "" {
message = "computer tool reported an error"
}
lowerMessage := strings.ToLower(message)
if strings.Contains(lowerMessage, "0x00000000") || strings.Contains(message, "操作成功完成") {
return fmt.Sprintf("computer driver returned an inconsistent result (isError=true with an OS success status): %s", message)
}
if strings.Contains(lowerMessage, "authorization process") &&
(strings.Contains(lowerMessage, "own") || strings.Contains(lowerMessage, "self")) {
return fmt.Sprintf("computer driver rejected its protected authorization UI as an automation target: %s", message)
}
return message
}
type mcpToolCatalog struct {
SchemaVersion string `json:"schema_version"`
CapabilityVersion string `json:"capability_version"`
Tools []ToolDefinition `json:"tools"`
}
func parseToolCatalog(raw json.RawMessage) (ToolCatalog, error) {
var parsed mcpToolCatalog
if err := json.Unmarshal(raw, &parsed); err != nil {
return ToolCatalog{}, fmt.Errorf("decode computer tool catalog: %w", err)
}
if parsed.Tools == nil {
parsed.Tools = []ToolDefinition{}
}
return ToolCatalog(parsed), nil
}