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

693 lines
21 KiB
Go

package agentruntime
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"github.com/tutti-os/tutti/packages/agent/daemon/httpx"
)
var ErrPromptImageUnsupported = errors.New("agent prompt image input is unsupported")
const clientSubmitUserMessageIDPrefix = "client-submit:user:"
const turnUserMessageIDPrefix = "turn-user:"
const maxProviderPromptImageBytes int64 = 20 << 20
var runtimeConnectorKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,127}$`)
type canonicalSubmitFactContextKey struct{}
type promptActivityMessageIDContextKey struct{}
type canonicalPromptContentContextKey struct{}
type canonicalSubmitFact struct {
clientSubmitID string
messageID string
occurredAtUnixMS int64
}
func newCanonicalSubmitFact(clientSubmitID string, occurredAtUnixMS int64) (canonicalSubmitFact, error) {
clientSubmitID = strings.TrimSpace(clientSubmitID)
if clientSubmitID == "" {
if occurredAtUnixMS > 0 {
return canonicalSubmitFact{}, errors.New("canonical submit occurrence requires a client submit id")
}
return canonicalSubmitFact{}, nil
}
if occurredAtUnixMS <= 0 {
return canonicalSubmitFact{}, errors.New("canonical submit occurrence time is required")
}
return canonicalSubmitFact{
clientSubmitID: clientSubmitID,
messageID: userPromptActivityMessageIDFromClientSubmitID(clientSubmitID),
occurredAtUnixMS: occurredAtUnixMS,
}, nil
}
func withCanonicalSubmitFact(ctx context.Context, fact canonicalSubmitFact) context.Context {
if fact.clientSubmitID == "" {
return ctx
}
return context.WithValue(ctx, canonicalSubmitFactContextKey{}, fact)
}
func canonicalSubmitFactFromContext(ctx context.Context) canonicalSubmitFact {
if ctx == nil {
return canonicalSubmitFact{}
}
fact, _ := ctx.Value(canonicalSubmitFactContextKey{}).(canonicalSubmitFact)
return fact
}
func withPromptActivityMessageID(ctx context.Context, messageID string) context.Context {
messageID = strings.TrimSpace(messageID)
if messageID == "" {
return ctx
}
return context.WithValue(ctx, promptActivityMessageIDContextKey{}, messageID)
}
func promptActivityMessageIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
messageID, _ := ctx.Value(promptActivityMessageIDContextKey{}).(string)
return strings.TrimSpace(messageID)
}
func withCanonicalPromptContent(ctx context.Context, content []PromptContentBlock) context.Context {
if len(content) == 0 {
return ctx
}
return context.WithValue(ctx, canonicalPromptContentContextKey{}, append([]PromptContentBlock(nil), content...))
}
func canonicalPromptContentFromContext(ctx context.Context) []PromptContentBlock {
if ctx == nil {
return nil
}
content, _ := ctx.Value(canonicalPromptContentContextKey{}).([]PromptContentBlock)
return append([]PromptContentBlock(nil), content...)
}
type providerPromptImageMaterializer func(context.Context, []PromptContentBlock) ([]PromptContentBlock, error)
func normalizeRuntimePromptContent(content []PromptContentBlock) []PromptContentBlock {
out := make([]PromptContentBlock, 0, len(content))
for _, block := range content {
switch strings.TrimSpace(block.Type) {
case "text":
text := strings.TrimSpace(block.Text)
if text == "" {
continue
}
out = append(out, PromptContentBlock{Type: "text", Text: text})
case "image":
mimeType := strings.TrimSpace(block.MimeType)
data := strings.TrimSpace(block.Data)
imageURL := strings.TrimSpace(block.URL)
attachmentID := strings.TrimSpace(block.AttachmentID)
if !runtimePromptImageMimeTypeSupported(mimeType) ||
(data == "" && imageURL == "" && attachmentID == "") ||
(data != "" && imageURL != "") ||
(imageURL != "" && !runtimePromptImageURLSafe(imageURL)) {
continue
}
out = append(out, PromptContentBlock{
Type: "image",
MimeType: mimeType,
Data: data,
URL: imageURL,
AttachmentID: attachmentID,
Name: strings.TrimSpace(block.Name),
})
case "skill", "mention":
name := strings.TrimSpace(block.Name)
path := strings.TrimSpace(block.Path)
if name == "" || path == "" {
continue
}
out = append(out, PromptContentBlock{
Type: strings.TrimSpace(block.Type),
Name: name,
Path: path,
})
case "connector":
connectorKey := strings.TrimSpace(block.ConnectorKey)
if !runtimeConnectorKeyPattern.MatchString(connectorKey) {
continue
}
out = append(out, PromptContentBlock{Type: "connector", ConnectorKey: connectorKey})
}
}
return out
}
func normalizeRuntimePromptContentForValidation(content []PromptContentBlock) []PromptContentBlock {
out := make([]PromptContentBlock, 0, len(content))
for _, block := range content {
switch strings.TrimSpace(block.Type) {
case "text":
text := strings.TrimSpace(block.Text)
if text == "" {
continue
}
out = append(out, PromptContentBlock{Type: "text", Text: text})
case "image":
mimeType := strings.TrimSpace(block.MimeType)
data := strings.TrimSpace(block.Data)
imageURL := strings.TrimSpace(block.URL)
attachmentID := strings.TrimSpace(block.AttachmentID)
path := strings.TrimSpace(block.Path)
if !runtimePromptImageMimeTypeSupported(mimeType) ||
(data == "" && imageURL == "" && attachmentID == "" && path == "") ||
(data != "" && imageURL != "") ||
(imageURL != "" && !runtimePromptImageURLSafe(imageURL)) {
continue
}
out = append(out, PromptContentBlock{
Type: "image",
MimeType: mimeType,
Data: data,
URL: imageURL,
AttachmentID: attachmentID,
Name: strings.TrimSpace(block.Name),
Path: path,
})
case "skill", "mention":
name := strings.TrimSpace(block.Name)
path := strings.TrimSpace(block.Path)
if name == "" || path == "" {
continue
}
out = append(out, PromptContentBlock{
Type: strings.TrimSpace(block.Type),
Name: name,
Path: path,
})
case "connector":
connectorKey := strings.TrimSpace(block.ConnectorKey)
if !runtimeConnectorKeyPattern.MatchString(connectorKey) {
continue
}
out = append(out, PromptContentBlock{Type: "connector", ConnectorKey: connectorKey})
}
}
return out
}
func projectRuntimeConnectorPromptContent(content []PromptContentBlock, announced []string, skipDelta bool) (projected []PromptContentBlock, next []string) {
current := uniqueRuntimeConnectorKeys(content)
providerContent := make([]PromptContentBlock, 0, len(content)+1)
for _, block := range content {
if block.Type != "connector" {
providerContent = append(providerContent, block)
}
}
if skipDelta {
return providerContent, announced
}
enabled, disabled := diffRuntimeConnectorKeys(announced, current)
next = append([]string(nil), current...)
if len(enabled) == 0 && len(disabled) == 0 {
return providerContent, next
}
return append([]PromptContentBlock{{
Type: "text",
Text: formatRuntimeConnectorDeltaInstruction(enabled, disabled),
}}, providerContent...), next
}
func uniqueRuntimeConnectorKeys(content []PromptContentBlock) []string {
keys := make([]string, 0)
seen := make(map[string]struct{})
for _, block := range content {
if block.Type != "connector" {
continue
}
key := strings.TrimSpace(block.ConnectorKey)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
return keys
}
func diffRuntimeConnectorKeys(announced, current []string) (enabled, disabled []string) {
announcedSet := make(map[string]struct{}, len(announced))
for _, key := range announced {
key = strings.TrimSpace(key)
if key == "" {
continue
}
announcedSet[key] = struct{}{}
}
currentSet := make(map[string]struct{}, len(current))
for _, key := range current {
key = strings.TrimSpace(key)
if key == "" {
continue
}
currentSet[key] = struct{}{}
if _, ok := announcedSet[key]; !ok {
enabled = append(enabled, key)
}
}
for _, key := range announced {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := currentSet[key]; !ok {
disabled = append(disabled, key)
}
}
sort.Strings(enabled)
sort.Strings(disabled)
return enabled, disabled
}
func formatRuntimeConnectorDeltaInstruction(enabled, disabled []string) string {
parts := make([]string, 0, 2)
if len(enabled) > 0 {
parts = append(parts, fmt.Sprintf("Connector(s) enabled: %s.", strings.Join(enabled, ", ")))
}
if len(disabled) > 0 {
parts = append(parts, fmt.Sprintf("Connector(s) disabled: %s.", strings.Join(disabled, ", ")))
}
return strings.Join(parts, " ")
}
// prependConnectorRoutingUpdate renders a provider-only notice that the
// connector alias index changed after this session's instructions were
// materialized. Callers must keep the update out of canonical prompt content.
func prependConnectorRoutingUpdate(content []PromptContentBlock, update *string) []PromptContentBlock {
if update == nil {
return content
}
var instruction string
if index := strings.TrimSpace(*update); index == "" {
instruction = fmt.Sprintf(
"Connector routing update: no Tutti connectors are currently available. This supersedes the connector alias index in earlier instructions. Confirm availability with `%s connector available --json` before invoking any connector.",
tuttiCLICommandName(),
)
} else {
instruction = fmt.Sprintf(
"Connector routing update: aliases `%s`. This supersedes the connector alias index in earlier instructions. On an alias or `连接器`/`connector`, run `%s connector available --json` to discover native interfaces; connectors absent from this index are no longer available.",
index, tuttiCLICommandName(),
)
}
return append([]PromptContentBlock{{Type: "text", Text: instruction}}, content...)
}
func validatePromptContentImagesForPreflight(content []PromptContentBlock) error {
return validatePromptContentImages(content, true)
}
func validateRuntimePromptContentImages(content []PromptContentBlock) error {
return validatePromptContentImages(content, false)
}
func validatePromptContentImages(content []PromptContentBlock, allowPathOnly bool) error {
for _, block := range content {
if strings.TrimSpace(block.Type) != "image" {
continue
}
data := strings.TrimSpace(block.Data)
imageURL := strings.TrimSpace(block.URL)
attachmentID := strings.TrimSpace(block.AttachmentID)
path := strings.TrimSpace(block.Path)
hasSource := data != "" || imageURL != "" || attachmentID != "" || (allowPathOnly && path != "")
if !runtimePromptImageMimeTypeSupported(block.MimeType) ||
!hasSource ||
(data != "" && imageURL != "") ||
(imageURL != "" && !runtimePromptImageURLSafe(imageURL)) {
return ErrPromptImageUnsupported
}
}
return nil
}
func runtimePromptImageURLSafe(value string) bool {
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && parsed.Opaque == ""
}
func runtimePromptImageMimeTypeSupported(mimeType string) bool {
switch strings.TrimSpace(mimeType) {
case "image/png", "image/jpeg", "image/webp":
return true
default:
return false
}
}
func promptDisplayText(content []PromptContentBlock) string {
textParts := make([]string, 0, len(content))
imageCount := 0
for _, block := range content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
textParts = append(textParts, strings.TrimSpace(block.Text))
}
if block.Type == "image" {
imageCount++
}
}
if len(textParts) > 0 {
return strings.Join(textParts, "\n")
}
if imageCount == 1 {
return "[Image]"
}
if imageCount > 1 {
return "[Images]"
}
return ""
}
func explicitAndVisiblePromptText(content []PromptContentBlock, displayPrompt string) (string, string) {
explicitDisplayPrompt := strings.TrimSpace(displayPrompt)
if explicitDisplayPrompt != "" {
return explicitDisplayPrompt, explicitDisplayPrompt
}
return "", promptDisplayText(content)
}
func userPromptActivityPayload(content []PromptContentBlock, displayPrompt string, extra map[string]any) map[string]any {
payload := map[string]any{
"content": promptContentForActivity(content),
}
for key, value := range extra {
payload[key] = value
}
if explicitDisplayPrompt := strings.TrimSpace(displayPrompt); explicitDisplayPrompt != "" {
payload["displayPrompt"] = explicitDisplayPrompt
}
return payload
}
func newUserPromptActivityEvent(
ctx context.Context,
session Session,
content []PromptContentBlock,
explicitDisplayPrompt string,
visibleText string,
turnID string,
extra map[string]any,
) activityshared.Event {
payloadExtra := userPromptActivityPayloadExtraFromExecMetadata(ctx, extra)
fact := canonicalSubmitFactFromContext(ctx)
activityContent := canonicalPromptContentFromContext(ctx)
if len(activityContent) > 0 {
content = activityContent
if strings.TrimSpace(explicitDisplayPrompt) == "" {
_, visibleText = explicitAndVisiblePromptText(content, "")
}
}
if fact.clientSubmitID == "" {
fact.messageID = promptActivityMessageIDFromContext(ctx)
}
return newUserPromptActivityEventWithFact(
session,
content,
explicitDisplayPrompt,
visibleText,
turnID,
fact,
payloadExtra,
)
}
func newUserPromptActivityEventWithFact(
session Session,
content []PromptContentBlock,
explicitDisplayPrompt string,
visibleText string,
turnID string,
fact canonicalSubmitFact,
extra map[string]any,
) activityshared.Event {
eventID := newID()
occurredAtUnixMS := int64(0)
if fact.clientSubmitID != "" {
eventID = fact.messageID
occurredAtUnixMS = fact.occurredAtUnixMS
extra = clonePayload(extra)
if extra == nil {
extra = map[string]any{}
}
extra["clientSubmitId"] = fact.clientSubmitID
extra["messageId"] = fact.messageID
} else if fact.messageID != "" {
eventID = fact.messageID
extra = clonePayload(extra)
if extra == nil {
extra = map[string]any{}
}
extra["messageId"] = fact.messageID
}
return newTurnActivityEventWithIDAt(
session,
eventID,
EventMessage,
turnID,
"",
RoleUser,
visibleText,
userPromptActivityPayload(content, explicitDisplayPrompt, extra),
occurredAtUnixMS,
)
}
func userPromptActivityPayloadExtraFromExecMetadata(ctx context.Context, extra map[string]any) map[string]any {
return userPromptActivityPayloadExtraFromMetadata(execMetadataFromContext(ctx), extra)
}
func userPromptActivityPayloadExtraFromMetadata(metadata map[string]any, extra map[string]any) map[string]any {
clientSubmitID := metadataString(metadata, "clientSubmitId")
if clientSubmitID == "" {
return clonePayload(extra)
}
payload := clonePayload(extra)
if payload == nil {
payload = map[string]any{}
}
payload["clientSubmitId"] = clientSubmitID
if submittedAtUnixMS := metadataInt64(metadata, "clientSubmittedAtUnixMs"); submittedAtUnixMS > 0 {
payload["clientSubmittedAtUnixMs"] = submittedAtUnixMS
}
if strings.TrimSpace(payloadString(payload, "messageId")) == "" {
payload["messageId"] = userPromptActivityMessageIDFromClientSubmitID(clientSubmitID)
}
return payload
}
func userPromptActivityMessageIDFromClientSubmitID(clientSubmitID string) string {
normalized := strings.TrimSpace(clientSubmitID)
if normalized == "" {
return ""
}
return clientSubmitUserMessageIDPrefix + normalized
}
func newTurnUserPromptActivityMessageID() string {
return turnUserMessageIDPrefix + newID()
}
func promptContentForACP(content []PromptContentBlock) []map[string]any {
out := make([]map[string]any, 0, len(content))
for _, block := range content {
switch block.Type {
case "text":
out = append(out, map[string]any{
"type": "text",
"text": block.Text,
})
case "image":
out = append(out, map[string]any{
"type": "image",
"mimeType": block.MimeType,
"data": block.Data,
})
}
}
return out
}
// materializeProviderPromptImagesWithClient converts remote HTTPS image references at
// the provider boundary, immediately before Codex app-server or ACP receives
// the prompt. Current Codex and Claude transports reject remote image URLs and
// require inline image data. AgentGUI and durable activity state intentionally
// keep the uploaded URL; when a provider gains native URL support, only its
// final adapter needs to stop calling this compatibility conversion.
func materializeProviderPromptImages(ctx context.Context, content []PromptContentBlock) ([]PromptContentBlock, error) {
return materializeProviderPromptImagesWithClient(ctx, content, newProviderPromptImageHTTPClient(30*time.Second))
}
func materializeProviderPromptImagesAtBoundary(
ctx context.Context,
content []PromptContentBlock,
materializer providerPromptImageMaterializer,
) ([]PromptContentBlock, error) {
if materializer == nil {
materializer = materializeProviderPromptImages
}
return materializer(ctx, content)
}
func newProviderPromptImageHTTPClient(timeout time.Duration) *http.Client {
return httpx.NewClient(timeout)
}
func materializeProviderPromptImagesWithClient(ctx context.Context, content []PromptContentBlock, client *http.Client) ([]PromptContentBlock, error) {
requestClient := *client
existingRedirectCheck := client.CheckRedirect
requestClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
request.Header.Del("Referer")
if !runtimePromptImageURLSafe(request.URL.String()) {
return ErrPromptImageUnsupported
}
if existingRedirectCheck != nil {
return existingRedirectCheck(request, via)
}
return nil
}
out := append([]PromptContentBlock(nil), content...)
for index := range out {
block := out[index]
imageURL := strings.TrimSpace(block.URL)
if block.Type != "image" || imageURL == "" {
continue
}
if !runtimePromptImageURLSafe(imageURL) {
return nil, ErrPromptImageUnsupported
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return nil, fmt.Errorf("prepare remote prompt image: %w", err)
}
response, err := requestClient.Do(request)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, errors.New("download remote prompt image: request failed")
}
data, readErr := readProviderPromptImage(response, block.MimeType)
if closeErr := response.Body.Close(); readErr == nil && closeErr != nil {
readErr = closeErr
}
if readErr != nil {
return nil, readErr
}
block.Data = base64.StdEncoding.EncodeToString(data)
block.URL = ""
out[index] = block
}
return out, nil
}
func readProviderPromptImage(response *http.Response, expectedMimeType string) ([]byte, error) {
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("download remote prompt image: unexpected HTTP status %d", response.StatusCode)
}
if response.ContentLength > maxProviderPromptImageBytes {
return nil, fmt.Errorf("download remote prompt image: image exceeds %d bytes", maxProviderPromptImageBytes)
}
if contentType := strings.TrimSpace(response.Header.Get("Content-Type")); contentType != "" {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType != strings.TrimSpace(expectedMimeType) {
return nil, ErrPromptImageUnsupported
}
}
data, err := io.ReadAll(io.LimitReader(response.Body, maxProviderPromptImageBytes+1))
if err != nil {
return nil, fmt.Errorf("download remote prompt image: %w", err)
}
if len(data) == 0 || int64(len(data)) > maxProviderPromptImageBytes {
return nil, fmt.Errorf("download remote prompt image: invalid image size %d", len(data))
}
return data, nil
}
func promptContentForActivity(content []PromptContentBlock) []map[string]any {
out := make([]map[string]any, 0, len(content))
for _, block := range content {
switch block.Type {
case "text":
out = append(out, map[string]any{
"type": "text",
"text": block.Text,
})
case "image":
item := map[string]any{
"type": "image",
"mimeType": block.MimeType,
"attachmentId": block.AttachmentID,
}
if imageURL := strings.TrimSpace(block.URL); imageURL != "" {
item["url"] = imageURL
}
if strings.TrimSpace(block.Name) != "" {
item["name"] = strings.TrimSpace(block.Name)
}
out = append(out, item)
case "connector":
out = append(out, map[string]any{
"type": "connector",
"connectorKey": block.ConnectorKey,
})
}
}
return out
}
func promptContentHasImage(content []PromptContentBlock) bool {
for _, block := range content {
if block.Type == "image" {
return true
}
}
return false
}
func acpPromptImageSupported(raw json.RawMessage) bool {
var result map[string]any
if len(raw) == 0 || json.Unmarshal(raw, &result) != nil {
return false
}
return truthyNested(result, "promptCapabilities", "image") ||
truthyNested(result, "agentCapabilities", "promptImage") ||
truthyNested(result, "agentCapabilities", "image") ||
acpAgentCapabilitiesPromptImageSupported(result)
}
// acpAgentCapabilitiesPromptImageSupported reads the standard ACP initialize
// shape: agentCapabilities.promptCapabilities.image.
func acpAgentCapabilitiesPromptImageSupported(result map[string]any) bool {
agentCapabilities, ok := result["agentCapabilities"].(map[string]any)
if !ok {
return false
}
return truthyNested(agentCapabilities, "promptCapabilities", "image")
}