Files
tutti/packages/agent/daemon/runtime/codex_appserver_event_info.go
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

387 lines
12 KiB
Go

package agentruntime
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"strings"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
var (
appServerFileCitationPattern = regexp.MustCompile(`:codex-file-citation\{([^{}\r\n]*)\}`)
appServerFileCitationAttributePattern = regexp.MustCompile(`(?:^|\s)(path|purpose)="([^"\r\n]*)"`)
)
func (a *CodexAppServerAdapter) appServerInfo(raw json.RawMessage) map[string]any {
runtimeName := ""
displayName := ""
if a != nil {
runtimeName = strings.TrimSpace(a.config.runtimeName)
displayName = strings.TrimSpace(a.config.displayName)
}
info := map[string]any{
"name": runtimeName,
"title": displayName,
}
if len(raw) == 0 {
return info
}
var result struct {
UserAgent string `json:"userAgent"`
CodexHome string `json:"codexHome"`
// The Tutti Agent fork renames the initialize home field; its serde
// alias only applies to deserialization, so both spellings must be
// accepted here.
TuttiAgentHome string `json:"tuttiAgentHome"`
PlatformOS string `json:"platformOs"`
PlatformFamily string `json:"platformFamily"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return info
}
if result.UserAgent != "" {
info["userAgent"] = result.UserAgent
}
if result.CodexHome == "" {
result.CodexHome = result.TuttiAgentHome
}
if result.CodexHome != "" {
info["codexHome"] = result.CodexHome
}
if result.PlatformOS != "" {
info["platformOs"] = result.PlatformOS
}
return info
}
func appServerThreadID(raw json.RawMessage) (string, error) {
var result struct {
Thread struct {
ID string `json:"id"`
} `json:"thread"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
if strings.TrimSpace(result.Thread.ID) == "" {
return "", errors.New("app-server thread/start returned empty thread id")
}
return strings.TrimSpace(result.Thread.ID), nil
}
func appServerTurnFromResult(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return nil
}
var result struct {
Turn map[string]any `json:"turn"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return nil
}
return result.Turn
}
func appServerTurnFinalAssistantText(turn map[string]any) string {
items, _ := turn["items"].([]any)
for index := len(items) - 1; index >= 0; index-- {
item := payloadObject(items[index])
if asString(item["type"]) == "agentMessage" {
return normalizeAppServerFileCitations(strings.TrimSpace(asStringRaw(item["text"])))
}
}
return ""
}
func normalizeAppServerFileCitations(text string) string {
return appServerFileCitationPattern.ReplaceAllStringFunc(text, func(marker string) string {
body := strings.TrimSuffix(strings.TrimPrefix(marker, ":codex-file-citation{"), "}")
attributes := appServerFileCitationAttributePattern.FindAllStringSubmatch(body, -1)
values := make(map[string]string, len(attributes))
for _, attribute := range attributes {
if len(attribute) != 3 || values[attribute[1]] != "" {
return marker
}
values[attribute[1]] = attribute[2]
}
filePath := strings.TrimSpace(values["path"])
if values["purpose"] != "output" || !isAppServerAbsoluteFilePath(filePath) {
return marker
}
normalizedPath := filePath
if isAppServerWindowsAbsoluteFilePath(normalizedPath) {
normalizedPath = strings.ReplaceAll(normalizedPath, `\`, "/")
}
fileName := normalizedPath
if separator := strings.LastIndex(normalizedPath, "/"); separator >= 0 {
fileName = normalizedPath[separator+1:]
}
if fileName == "" {
return marker
}
return fmt.Sprintf(
"[@%s](<%s>)",
escapeAppServerMarkdownLabel(fileName),
escapeAppServerMarkdownPath(normalizedPath),
)
})
}
func isAppServerAbsoluteFilePath(filePath string) bool {
return (strings.HasPrefix(filePath, "/") && !strings.HasPrefix(filePath, "//")) ||
isAppServerWindowsAbsoluteFilePath(filePath)
}
func isAppServerWindowsAbsoluteFilePath(filePath string) bool {
return len(filePath) >= 3 &&
((filePath[0] >= 'a' && filePath[0] <= 'z') || (filePath[0] >= 'A' && filePath[0] <= 'Z')) &&
filePath[1] == ':' &&
(filePath[2] == '/' || filePath[2] == '\\')
}
func escapeAppServerMarkdownLabel(label string) string {
replacer := strings.NewReplacer(`\`, `\\`, "[", `\[`, "]", `\]`)
return replacer.Replace(label)
}
func escapeAppServerMarkdownPath(filePath string) string {
segments := strings.Split(filePath, "/")
for index, segment := range segments {
if index == 0 && isAppServerWindowsAbsoluteFilePath(filePath) {
continue
}
segments[index] = url.PathEscape(segment)
}
return strings.Join(segments, "/")
}
func appServerTurnTerminalEvents(
session Session,
turnID string,
turn map[string]any,
normalizer *acpTurnNormalizer,
) []activityshared.Event {
status := asString(turn["status"])
providerTurnID := firstNonEmpty(asString(turn["id"]), turnID)
switch status {
case "interrupted":
events := normalizer.FinishInterrupted(session, turnID, "interrupted")
terminal := appServerRootProviderTurnCompletedEvent(session, turnID, providerTurnID, activityshared.TurnOutcomeCanceled, map[string]any{"stopReason": "canceled"})
return append(events, terminal)
case "failed":
events := normalizer.FinishFailed(session, turnID)
metadata := map[string]any{
"stopReason": "failed",
}
if turnError := payloadObject(turn["error"]); len(turnError) > 0 {
for key, value := range failureFromCodexTurnError(turnError).metadata() {
metadata[key] = value
}
if codexErrorInfo := turnError["codexErrorInfo"]; codexErrorInfo != nil {
metadata["codexErrorInfo"] = clonePayloadValue(codexErrorInfo)
}
}
terminal := appServerRootProviderTurnCompletedEvent(session, turnID, providerTurnID, activityshared.TurnOutcomeFailed, metadata)
return append(events, terminal)
default:
events := normalizer.FinishCompleted(session, turnID)
terminal := appServerRootProviderTurnCompletedEvent(session, turnID, providerTurnID, activityshared.TurnOutcomeCompleted, map[string]any{"stopReason": "end_turn"})
return append(events, terminal)
}
}
func appServerRootProviderTurnCompletedEvent(
session Session,
rootTurnID string,
providerTurnID string,
outcome activityshared.TurnOutcome,
metadata map[string]any,
) activityshared.Event {
providerTurnID = firstNonEmpty(strings.TrimSpace(providerTurnID), strings.TrimSpace(rootTurnID))
ctx, ok := activityEventContext(session, "root-provider-turn-completed:"+providerTurnID, rootTurnID)
if !ok {
return activityshared.Event{}
}
event := activityshared.NewRootProviderTurnCompleted(ctx, rootTurnID, providerTurnID, outcome)
event.Payload.Metadata = clonePayload(metadata)
return event
}
// --- session capability metadata ---
func codexAppServerCommands() []AgentSessionCommand {
return []AgentSessionCommand{
{Name: "review", Description: "Review code changes", InputHint: "instructions (optional)"},
{Name: "goal", Description: "Show or update the thread goal", InputHint: "objective, status, or clear"},
{Name: "compact", Description: "Compact the conversation context"},
{Name: "undo", Description: "Drop the last turn from the conversation"},
}
}
func codexAppServerCapabilities(planMode bool) []string {
capabilities := []string{
CapabilityImageInput,
CapabilitySkills,
CapabilityInterrupt,
CapabilityActiveTurnGuidance,
CapabilityCompact,
CapabilityRateLimits,
CapabilityTokenUsage,
"review",
"goal",
CapabilityGoalPause,
CapabilityPlanImplementation,
CapabilityPermissionModeChangeDuringTurn,
CapabilityPermissionModeChangeDeferred,
"rollback",
"fork",
"perTurnModelOverride",
}
if planMode {
// Negotiated at session start via the experimental
// collaborationMode/list probe.
capabilities = append(capabilities, CapabilityPlanMode)
}
return capabilities
}
// asStringRaw returns string values without trimming, so streaming deltas keep
// their whitespace. Non-strings return "".
func asStringRaw(value any) string {
typed, _ := value.(string)
return typed
}
// appServerSearchQueries reads action.queries[] (Codex app-server webSearch
// schema) into a []any of non-empty trimmed strings, suitable for the GUI's
// search_query rendering. Returns nil when absent or empty.
func appServerSearchQueries(value any) []any {
raw, ok := value.([]any)
if !ok {
return nil
}
out := make([]any, 0, len(raw))
for _, entry := range raw {
if text := asString(entry); text != "" {
out = append(out, text)
}
}
if len(out) == 0 {
return nil
}
return out
}
// firstAppServerQuery returns the first non-empty query string from a
// search_query list produced by appServerSearchQueries.
func firstAppServerQuery(queries []any) string {
for _, q := range queries {
if text := asString(q); text != "" {
return text
}
}
return ""
}
// appServerItemJSON renders a raw thread item as compact JSON for diagnostics,
// falling back to Go formatting if the item is not JSON-serializable.
func appServerItemJSON(item map[string]any) string {
if encoded, err := json.Marshal(item); err == nil {
return string(encoded)
}
return fmt.Sprintf("%+v", item)
}
// appServerReasoningText pulls the human-readable text out of a completed
// `reasoning` thread item. Per the Codex app-server schema, the ThreadItem
// reasoning variant is {id, summary, content} where summary and content are
// usually string arrays. Some app-server versions also stream reasoning via
// summaryTextDelta/textDelta and may emit completed items before those arrays
// are populated, so callers should still handle streaming deltas.
func appServerReasoningText(item map[string]any) string {
if text := reasoningSectionsText(item["summary"]); text != "" {
return text
}
if text := reasoningSectionsText(item["content"]); text != "" {
return text
}
return firstNonEmpty(asStringRaw(item["text"]), asString(item["text"]))
}
// appServerReasoningDeltaText reads a reasoning delta payload. Most app-server
// versions use `delta`, but some event shapes expose the chunk as `text`.
// Streaming chunks must preserve their leading/trailing whitespace (e.g. a
// "Need " token followed by "context.") so concatenated reasoning text keeps
// word boundaries; do not trim here.
func appServerReasoningDeltaText(params map[string]any) string {
if delta := asStringRaw(params["delta"]); delta != "" {
return delta
}
return asStringRaw(params["text"])
}
// reasoningSectionsText joins the non-empty sections of a reasoning
// summary/content value, separating sections with a blank line.
func reasoningSectionsText(value any) string {
switch typed := value.(type) {
case string:
return typed
case []string:
return joinReasoningSectionTexts(typed)
case []any:
parts := make([]string, 0, len(typed))
for _, raw := range typed {
if text := reasoningSectionText(raw); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n\n")
default:
return ""
}
}
func joinReasoningSectionTexts(values []string) string {
var b strings.Builder
for _, value := range values {
text := asStringRaw(value)
if text == "" {
continue
}
if b.Len() > 0 {
b.WriteString("\n\n")
}
b.WriteString(text)
}
return b.String()
}
func reasoningSectionText(raw any) string {
if text := asStringRaw(raw); text != "" {
return text
}
item, ok := raw.(map[string]any)
if !ok {
return ""
}
return firstNonEmpty(
asStringRaw(item["text"]),
asStringRaw(item["summary_text"]),
asStringRaw(item["summaryText"]),
asStringRaw(item["summary"]),
asStringRaw(item["content"]),
asString(item["text"]),
asString(item["summary_text"]),
asString(item["summaryText"]),
asString(item["summary"]),
asString(item["content"]),
)
}