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
387 lines
12 KiB
Go
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"]),
|
|
)
|
|
}
|