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

655 lines
23 KiB
Go

package agentcontext
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
agenthost "github.com/tutti-os/tutti/packages/agent/host"
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
"github.com/tutti-os/tutti/services/tuttid/biz/agentgui"
agenttargetbiz "github.com/tutti-os/tutti/services/tuttid/biz/agenttarget"
agentservice "github.com/tutti-os/tutti/services/tuttid/service/agent"
cliservice "github.com/tutti-os/tutti/services/tuttid/service/cli"
"github.com/tutti-os/tutti/services/tuttid/service/cli/framework"
)
var sessionActionColumns = []cliservice.TableColumn{
{Key: "id", Label: "ID"},
{Key: "provider", Label: "Provider"},
{Key: "activeTurnId", Label: "Active Turn"},
{Key: "launchRequested", Label: "Launch Requested"},
}
type startInput struct {
AgentID string `cli:"agent-id" advertise-required:"true" hint:"Use agent list --json to discover available agents."`
Cwd string `cli:"cwd"`
DisplayPrompt string `cli:"display-prompt"`
Hidden bool `cli:"hidden"`
Images []string `cli:"image" description:"Image file to attach to the initial prompt. May be passed multiple times."`
Isolation string `cli:"isolation" enum:"worktree" description:"Run the new session in a dedicated git worktree."`
Model string `cli:"model"`
PermissionMode string `cli:"permission-mode"`
Prompt string `cli:"prompt" validate:"required"`
Provider string `cli:"provider" hidden:"true"`
ReasoningEffort string `cli:"reasoning-effort"`
Show bool `cli:"show"`
Speed string `cli:"speed"`
Title string `cli:"title"`
}
type sessionIDInput struct {
SessionID string `cli:"session-id" validate:"required"`
}
type cancelTurnInput struct {
SessionID string `cli:"session-id" validate:"required" description:"Agent session id containing the turn to cancel."`
TurnID string `cli:"turn-id" validate:"required" description:"Exact turn id to cancel."`
}
type sendInput struct {
SessionID string `cli:"session-id" validate:"required"`
Guidance bool `cli:"guidance" description:"Capture and guide the exact active turn instead of starting a new turn."`
Images []string `cli:"image" description:"Image file to attach to this prompt. May be passed multiple times."`
Prompt string `cli:"prompt" validate:"required"`
}
type respondInput struct {
SessionID string `cli:"session-id" validate:"required" description:"Agent session id containing the pending interaction."`
TurnID string `cli:"turn-id" validate:"required" description:"Exact turn id containing the pending interaction."`
RequestID string `cli:"request-id" validate:"required" description:"Pending interaction request id."`
Action string `cli:"action" description:"Provider action id to submit."`
Option string `cli:"option" description:"Provider option id to submit."`
Payload string `cli:"payload" description:"JSON object payload to submit."`
Semantic string `cli:"semantic" description:"Resolve one uniquely matching action semantic from the pending interaction."`
}
type sessionActionResult struct {
Session agentservice.Session
WorkspaceID string
TurnID string
LaunchRequested bool
WaitAfterVersion *uint64
Warnings []cliservice.CommandWarning
}
type cancelTurnCommandResult struct {
AgentSessionID string
TurnID string
Result agentservice.CancelTurnResult
}
func (p Provider) newStartCommand() cliservice.Command {
return framework.Register(framework.CommandSpec[startInput]{
ID: appID + ".agent.start",
Path: []string{"agent", "start"},
Summary: "Start an agent session",
Description: "Start an agent session by agent id. Use agent list --json to discover the currently available agents.",
Kind: framework.KindAction,
Workspace: framework.WorkspaceRequired,
Workspaces: p.workspaces,
Inputs: framework.FromStruct[startInput](),
Output: sessionActionOutputSpec(),
Run: func(ctx context.Context, invoke framework.InvokeContext, input startInput) (any, error) {
target, _, err := p.resolveAgentSelector(ctx, input.AgentID, input.Provider)
if err != nil {
return nil, err
}
return p.runStart(ctx, invoke, target, startFields{
Cwd: input.Cwd,
DisplayPrompt: input.DisplayPrompt,
Hidden: input.Hidden,
Images: input.Images,
Isolation: input.Isolation,
Model: input.Model,
PermissionMode: input.PermissionMode,
Prompt: input.Prompt,
ReasoningEffort: input.ReasoningEffort,
Show: input.Show,
Speed: input.Speed,
Title: input.Title,
})
},
})
}
type startFields struct {
Cwd string
DisplayPrompt string
Hidden bool
Images []string
Isolation string
Model string
PermissionMode string
Prompt string
ReasoningEffort string
Show bool
Speed string
Title string
}
func (p Provider) runStart(ctx context.Context, invoke framework.InvokeContext, target agenttargetbiz.Target, input startFields) (any, error) {
if err := p.requireSessions(); err != nil {
return nil, err
}
agentTargetID := strings.TrimSpace(target.ID)
if agentTargetID == "" {
return nil, fmt.Errorf("%w: agent target id is required", cliservice.ErrInvalidInput)
}
provider := strings.TrimSpace(target.Provider)
launchContext, err := resolveStartLaunchContext(input.Cwd, invoke.Request.Context)
if err != nil {
return nil, err
}
initialContent, err := promptContentFromCLIInput(input.Prompt, input.Images)
if err != nil {
return nil, err
}
created, err := p.sessions.CreateWithResult(ctx, invoke.WorkspaceID, agentservice.CreateSessionInput{
Provider: provider,
AgentTargetID: agentTargetID,
Cwd: optionalStringPointer(launchContext.cwd),
InitialContent: initialContent,
InitialDisplayPrompt: input.DisplayPrompt,
Model: optionalStringPointer(input.Model),
PermissionModeID: optionalStringPointer(input.PermissionMode),
ReasoningEffort: optionalStringPointer(input.ReasoningEffort),
Speed: optionalStringPointer(input.Speed),
Title: optionalStringPointer(input.Title),
Visible: hiddenVisibleOverride(input.Hidden),
ConversationDetailMode: p.composerConversationDetailMode(ctx),
Isolation: input.Isolation,
RailPlacement: launchContext.railPlacement,
ClientSubmitID: uuid.NewString(),
})
if err != nil {
return nil, err
}
session := created.Session
launchRequested := false
if input.Show {
if err := p.publishLaunchRequested(ctx, invoke.WorkspaceID, session, "start_show", invoke.Request.Context.Source); err != nil {
return nil, err
}
launchRequested = true
}
warnings := make([]cliservice.CommandWarning, 0, len(session.Warnings))
for _, warning := range session.Warnings {
warnings = append(warnings, cliservice.CommandWarning{Code: warning.Code, Message: warning.Message})
}
return sessionActionResult{
Session: session, TurnID: strings.TrimSpace(created.TurnID),
LaunchRequested: launchRequested, Warnings: warnings, WorkspaceID: invoke.WorkspaceID,
}, nil
}
func (p Provider) composerConversationDetailMode(ctx context.Context) string {
if p.preferences == nil {
return ""
}
preferences, err := p.preferences.Get(ctx)
if err != nil {
return ""
}
return preferences.AgentConversationDetailMode
}
func hiddenVisibleOverride(hidden bool) *bool {
if !hidden {
return nil
}
visible := false
return &visible
}
type startLaunchContext struct {
cwd string
railPlacement *agenthost.RailPlacement
}
func resolveStartLaunchContext(
explicit string,
invokeContext cliservice.InvokeContext,
) (startLaunchContext, error) {
if cwd := strings.TrimSpace(explicit); cwd != "" {
return startLaunchContext{cwd: cwd}, nil
}
cwd := strings.TrimSpace(invokeContext.AgentCWD)
encodedPlacement := strings.TrimSpace(invokeContext.AgentRailPlacementJSON)
if cwd == "" && encodedPlacement == "" {
return startLaunchContext{}, nil
}
if cwd == "" || encodedPlacement == "" {
return startLaunchContext{}, fmt.Errorf(
"%w: inherited Agent cwd and rail placement must be provided together",
cliservice.ErrInvalidInput,
)
}
placement, err := agenthost.ParseAgentRailPlacementEnvironment(encodedPlacement)
if err != nil {
return startLaunchContext{}, fmt.Errorf(
"%w: invalid inherited Agent rail placement: %v",
cliservice.ErrInvalidInput,
err,
)
}
return startLaunchContext{
cwd: cwd,
railPlacement: placement,
}, nil
}
func (p Provider) newOpenCommand() cliservice.Command {
return framework.Register(framework.CommandSpec[sessionIDInput]{
ID: appID + ".agent.open",
Path: []string{"agent", "open"},
Summary: "Request AgentGUI open",
Description: "Request desktop AgentGUI activation for an existing agent session.",
Kind: framework.KindAction,
Workspace: framework.WorkspaceRequired,
Workspaces: p.workspaces,
Inputs: framework.FromStruct[sessionIDInput](),
Output: sessionActionOutputSpec(),
Run: p.runOpen,
})
}
func (p Provider) runOpen(ctx context.Context, invoke framework.InvokeContext, input sessionIDInput) (any, error) {
if err := p.requireSessions(); err != nil {
return nil, err
}
session, err := p.sessions.Get(ctx, invoke.WorkspaceID, input.SessionID)
if err != nil {
return nil, err
}
if err := p.publishLaunchRequested(ctx, invoke.WorkspaceID, session, "open", invoke.Request.Context.Source); err != nil {
return nil, err
}
return sessionActionResult{Session: session, LaunchRequested: true, WorkspaceID: invoke.WorkspaceID}, nil
}
func (p Provider) newGetCommand() cliservice.Command {
return framework.Register(framework.CommandSpec[getSessionInput]{
ID: appID + ".agent.get",
Path: []string{"agent", "get"},
Summary: "Get an agent session",
Description: "Get recent turn conversation by default, session metadata only, or a detailed trace for one turn.",
Kind: framework.KindGet,
Workspace: framework.WorkspaceRequired,
Workspaces: p.workspaces,
Inputs: framework.FromStruct[getSessionInput](),
Output: framework.OutputSpec{
DefaultMode: cliservice.OutputModeJSON,
DefaultView: framework.ViewDetail,
JSON: true,
JSONViews: map[framework.OutputView]func(any) map[string]any{framework.ViewDetail: sessionGetJSONValue},
},
Run: p.runGet,
})
}
func (p Provider) runGet(ctx context.Context, invoke framework.InvokeContext, input getSessionInput) (any, error) {
return p.getSessionContext(ctx, invoke.WorkspaceID, input)
}
func (p Provider) newSendCommand() cliservice.Command {
return framework.Register(framework.CommandSpec[sendInput]{
ID: appID + ".agent.send",
Path: []string{"agent", "send"},
Summary: "Send input to an agent session",
Description: "Send user input to an existing agent session. Use --guidance to guide the currently active turn without attaching to output.",
Kind: framework.KindAction,
Workspace: framework.WorkspaceRequired,
Workspaces: p.workspaces,
Inputs: framework.FromStruct[sendInput](),
Output: sessionActionOutputSpec(),
Run: p.runSend,
})
}
func (p Provider) runSend(ctx context.Context, invoke framework.InvokeContext, input sendInput) (any, error) {
if err := p.requireSessions(); err != nil {
return nil, err
}
content, err := promptContentFromCLIInput(input.Prompt, input.Images)
if err != nil {
return nil, err
}
turnID := ""
if input.Guidance {
session, err := p.sessions.Get(ctx, invoke.WorkspaceID, input.SessionID)
if err != nil {
return nil, err
}
turnID = strings.TrimSpace(session.ActiveTurnID)
if turnID == "" {
return nil, agentservice.ErrActiveTurnTargetRequired
}
}
messagePage, err := p.sessions.ListMessages(ctx, invoke.WorkspaceID, input.SessionID, agentservice.ListMessagesInput{
Limit: 1,
Order: agentactivitybiz.MessageOrderDesc,
})
if err != nil {
return nil, err
}
waitAfterVersion := messagePage.LatestVersion
result, err := p.sessions.SendInput(ctx, invoke.WorkspaceID, input.SessionID, agentservice.SendInput{
Content: content,
Guidance: input.Guidance,
TurnID: turnID,
ClientSubmitID: uuid.NewString(),
})
if err != nil {
return nil, err
}
session := result.Session
return sessionActionResult{
Session: session, TurnID: strings.TrimSpace(result.TurnID), WaitAfterVersion: &waitAfterVersion,
WorkspaceID: invoke.WorkspaceID,
}, nil
}
func (p Provider) newRespondCommand() cliservice.Command {
return framework.Register(framework.CommandSpec[respondInput]{
ID: appID + ".agent.respond",
Path: []string{"agent", "respond"},
Summary: "Respond to a pending agent interaction",
Description: "Answer a pending agent approval or input request by action, option, payload, or self-described semantic.",
Kind: framework.KindAction,
Workspace: framework.WorkspaceRequired,
Workspaces: p.workspaces,
Inputs: framework.FromStruct[respondInput](),
Output: framework.OutputSpec{
DefaultMode: cliservice.OutputModeJSON,
DefaultView: framework.ViewSummary,
JSON: true,
JSONViews: map[framework.OutputView]func(any) map[string]any{
framework.ViewSummary: func(result any) map[string]any {
responded := result.(agentservice.RespondResult)
return map[string]any{
"requestId": responded.RequestID, "turnId": responded.TurnID,
"disposition": string(responded.Disposition),
}
},
},
},
Run: p.runRespond,
})
}
func (p Provider) runRespond(ctx context.Context, invoke framework.InvokeContext, input respondInput) (any, error) {
if err := p.requireSessions(); err != nil {
return nil, err
}
if strings.TrimSpace(input.Action) != "" && strings.TrimSpace(input.Semantic) != "" {
return nil, fmt.Errorf("%w: action and semantic are mutually exclusive", cliservice.ErrInvalidInput)
}
var payload map[string]any
payloadProvided := strings.TrimSpace(input.Payload) != ""
if payloadProvided {
if err := json.Unmarshal([]byte(input.Payload), &payload); err != nil || payload == nil {
return nil, fmt.Errorf("%w: payload must be a JSON object", cliservice.ErrInvalidInput)
}
}
if strings.TrimSpace(input.Action) == "" && strings.TrimSpace(input.Option) == "" &&
strings.TrimSpace(input.Semantic) == "" && !payloadProvided {
return nil, fmt.Errorf("%w: provide action, option, payload, or semantic", cliservice.ErrInvalidInput)
}
result, err := p.sessions.Respond(ctx, agentservice.RespondInput{
WorkspaceID: invoke.WorkspaceID, AgentSessionID: input.SessionID, TurnID: input.TurnID, RequestID: input.RequestID,
Action: optionalStringPointer(input.Action), OptionID: optionalStringPointer(input.Option),
Payload: payload, Semantic: input.Semantic,
})
if err != nil {
if isRespondInputError(err) {
return nil, fmt.Errorf("%w: %v", cliservice.ErrInvalidInput, err)
}
return nil, err
}
return result, nil
}
func isRespondInputError(err error) bool {
return errors.Is(err, agentservice.ErrInvalidArgument) ||
errors.Is(err, agentservice.ErrInteractionRequestNotFound) ||
errors.Is(err, agentservice.ErrInteractionSemanticNotFound) ||
errors.Is(err, agentservice.ErrInteractionSemanticAmbiguous)
}
func promptContentFromCLIInput(prompt string, imagePaths []string) ([]agentservice.PromptContentBlock, error) {
content := agentservice.TextPromptContent(prompt)
for _, imagePath := range normalizeCLIImagePaths(imagePaths) {
block, err := promptImageContentBlockFromFile(imagePath)
if err != nil {
return nil, err
}
content = append(content, block)
}
return content, nil
}
func normalizeCLIImagePaths(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func promptImageContentBlockFromFile(path string) (agentservice.PromptContentBlock, error) {
mimeType := promptImageMimeTypeFromPath(path)
if mimeType == "" {
return agentservice.PromptContentBlock{}, fmt.Errorf("%w: invalid input %q, expected a PNG, JPEG, or WebP image", cliservice.ErrInvalidInput, "image")
}
data, err := os.ReadFile(path)
if err != nil {
return agentservice.PromptContentBlock{}, fmt.Errorf("%w: read image %q: %v", cliservice.ErrInvalidInput, path, err)
}
return agentservice.PromptContentBlock{
Type: "image",
MimeType: mimeType,
Data: base64.StdEncoding.EncodeToString(data),
Name: filepath.Base(path),
}, nil
}
func promptImageMimeTypeFromPath(path string) string {
switch strings.ToLower(strings.TrimSpace(filepath.Ext(path))) {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
default:
return ""
}
}
func (p Provider) newCancelTurnCommand() cliservice.Command {
return framework.Register(framework.CommandSpec[cancelTurnInput]{
ID: appID + ".agent.cancel-turn",
Path: []string{"agent", "cancel-turn"},
Summary: "Cancel an agent turn",
Description: "Cancel one exact turn while preserving its agent session for later input.",
Kind: framework.KindAction,
Workspace: framework.WorkspaceRequired,
Workspaces: p.workspaces,
Inputs: framework.FromStruct[cancelTurnInput](),
Output: cancelTurnOutputSpec(),
Run: p.runCancelTurn,
})
}
func (p Provider) runCancelTurn(ctx context.Context, invoke framework.InvokeContext, input cancelTurnInput) (any, error) {
if err := p.requireSessions(); err != nil {
return nil, err
}
result, err := p.sessions.CancelTurn(ctx, invoke.WorkspaceID, input.SessionID, input.TurnID)
if err != nil {
return nil, err
}
return cancelTurnCommandResult{
AgentSessionID: strings.TrimSpace(input.SessionID),
TurnID: strings.TrimSpace(input.TurnID),
Result: result,
}, nil
}
func cancelTurnOutputSpec() framework.OutputSpec {
columns := []cliservice.TableColumn{
{Key: "id", Label: "Session"},
{Key: "turnId", Label: "Turn"},
{Key: "canceled", Label: "Canceled"},
{Key: "reason", Label: "Reason"},
}
jsonValue := func(result any) map[string]any {
canceled := result.(cancelTurnCommandResult)
return map[string]any{
"agentSessionId": canceled.AgentSessionID,
"turnId": canceled.TurnID,
"canceled": canceled.Result.Canceled,
"reason": string(canceled.Result.Reason),
}
}
return framework.OutputSpec{
DefaultMode: cliservice.OutputModeTable,
DefaultView: framework.ViewSummary,
JSON: true,
Table: &framework.TableOutputSpec{
Columns: columns,
Rows: func(result any) []map[string]any {
canceled := result.(cancelTurnCommandResult)
return []map[string]any{{
"id": canceled.AgentSessionID,
"turnId": canceled.TurnID,
"canceled": canceled.Result.Canceled,
"reason": string(canceled.Result.Reason),
}}
},
},
JSONViews: map[framework.OutputView]func(any) map[string]any{
framework.ViewSummary: jsonValue,
},
}
}
func (p Provider) newLegacyCancelCommand() cliservice.Command {
return framework.Register(framework.CommandSpec[sessionIDInput]{
ID: appID + ".agent.cancel",
Path: []string{"agent", "cancel"},
Summary: "Cancel the active agent turn (deprecated)",
Description: "Deprecated compatibility alias. Use agent cancel-turn with an exact session id and turn id.",
Kind: framework.KindAction,
Visibility: cliservice.CapabilityVisibilityIntegration,
Workspace: framework.WorkspaceRequired,
Workspaces: p.workspaces,
Inputs: framework.FromStruct[sessionIDInput](),
Output: sessionActionOutputSpec(),
Run: p.runLegacyCancel,
})
}
func (p Provider) runLegacyCancel(ctx context.Context, invoke framework.InvokeContext, input sessionIDInput) (any, error) {
if err := p.requireSessions(); err != nil {
return nil, err
}
session, err := p.sessions.Get(ctx, invoke.WorkspaceID, input.SessionID)
if err != nil {
return nil, err
}
if turnID := strings.TrimSpace(session.ActiveTurnID); turnID != "" {
result, cancelErr := p.sessions.CancelTurn(ctx, invoke.WorkspaceID, input.SessionID, turnID)
if cancelErr != nil {
return nil, cancelErr
}
if strings.TrimSpace(result.Session.ID) != "" {
session = result.Session
}
}
return sessionActionResult{
Session: session, WorkspaceID: invoke.WorkspaceID,
Warnings: []cliservice.CommandWarning{{
Code: "deprecated_agent_cancel",
Message: "agent cancel is deprecated; use agent cancel-turn --session-id <id> --turn-id <id>",
}},
}, nil
}
func sessionActionOutputSpec() framework.OutputSpec {
return framework.OutputSpec{
DefaultMode: cliservice.OutputModeTable,
DefaultView: framework.ViewSummary,
JSON: true,
Table: &framework.TableOutputSpec{
Columns: sessionActionColumns,
Rows: func(result any) []map[string]any {
action := result.(sessionActionResult)
return []map[string]any{{
"id": action.Session.ID,
"provider": action.Session.Provider,
"activeTurnId": action.Session.ActiveTurnID,
"launchRequested": action.LaunchRequested,
}}
},
},
JSONViews: map[framework.OutputView]func(any) map[string]any{
framework.ViewSummary: func(result any) map[string]any {
action := result.(sessionActionResult)
value := map[string]any{
"launchRequested": action.LaunchRequested,
"session": sessionActionValue(action.WorkspaceID, action.Session),
}
addAgentSessionReference(value, action.WorkspaceID, action.Session.ID)
if turnID := strings.TrimSpace(action.TurnID); turnID != "" {
value["turnId"] = turnID
}
if action.WaitAfterVersion != nil {
value["waitAfterVersion"] = *action.WaitAfterVersion
}
return value
},
},
Warnings: func(result any) []cliservice.CommandWarning {
return append([]cliservice.CommandWarning(nil), result.(sessionActionResult).Warnings...)
},
}
}
func (p Provider) publishLaunchRequested(
ctx context.Context,
workspaceID string,
session agentservice.Session,
reason string,
source string,
) error {
if p.launchPublisher == nil {
return nil
}
return p.launchPublisher.PublishAgentGUILaunchRequested(ctx, agentgui.NormalizeLaunchRequest(agentgui.LaunchRequest{
WorkspaceID: workspaceID,
AgentSessionID: session.ID,
AgentTargetID: session.AgentTargetID,
Provider: session.Provider,
Source: source,
Reason: reason,
}))
}
func optionalStringPointer(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}