Files
kandev/apps/backend/internal/task/models/tool_payload_retention.go
T
2026-09-15 09:38:21 +01:00

270 lines
7.7 KiB
Go

package models
import (
"encoding/json"
"strconv"
"strings"
"time"
)
const ToolPayloadMaxBytes = 32 * 1024 * 1024
const payloadRetentionKey = "payload_retention"
const payloadMalformed = "malformed"
type PayloadReduction struct {
Metadata []byte
RemovedBytes int64
Reason string
}
type rawPayloadObject = map[string]json.RawMessage
// ToolPayloadRemoved reports a persisted removal tombstone, including future versions.
func ToolPayloadRemoved(metadata map[string]any) bool {
_, removed := metadata[payloadRetentionKey]
return removed
}
func ReduceToolPayload(messageType string, metadata []byte, removedAt time.Time) (PayloadReduction, error) {
result := PayloadReduction{Metadata: metadata}
if len(metadata) > ToolPayloadMaxBytes {
result.Reason = "oversize"
return result, nil
}
root, ok := payloadObject(metadata)
if !ok {
result.Reason = payloadMalformed
return result, nil
}
if _, exists := root[payloadRetentionKey]; exists {
result.Reason = "already_removed"
return result, nil
}
norm, ok := payloadObject(root["normalized"])
if !ok {
result.Reason = payloadMalformed
return result, nil
}
var kind string
_ = json.Unmarshal(norm["kind"], &kind)
if !supportedPayload(kind, messageType, norm) {
result.Reason = "unsupported"
return result, nil
}
body, ok := payloadObject(norm[kind])
if !ok {
result.Reason = payloadMalformed
return result, nil
}
if kind == "generic" && !reducibleGeneric(body, root["result"]) {
result.Reason = "unsupported"
return result, nil
}
paths, ok, err := removePayloadFields(kind, body)
if err != nil {
return result, err
}
if !ok {
result.Reason = payloadMalformed
return result, nil
}
if _, exists := root["result"]; exists {
delete(root, "result")
paths = append(paths, "result")
}
if len(paths) == 0 {
result.Reason = "no_payload"
return result, nil
}
if err := updateReducedPayload(root, norm, body, kind); err != nil {
return result, err
}
return encodePayloadRemoval(root, paths, metadata, removedAt)
}
func supportedPayload(kind, messageType string, norm rawPayloadObject) bool {
expected := map[string]string{"shell_exec": "tool_execute", "read_file": "tool_read", "modify_file": "tool_edit", "code_search": "tool_search", "generic": "tool_call", "http_request": "tool_call"}
return expected[kind] != "" && expected[kind] == messageType && !protectedPayload(norm)
}
func updateReducedPayload(root, norm, body rawPayloadObject, kind string) error {
encodedBody, err := json.Marshal(body)
if err != nil {
return err
}
norm[kind] = encodedBody
encodedNorm, err := json.Marshal(norm)
if err != nil {
return err
}
root["normalized"] = encodedNorm
return nil
}
func encodePayloadRemoval(root rawPayloadObject, paths []string, metadata []byte, removedAt time.Time) (PayloadReduction, error) {
marker := map[string]any{"version": 1, "removed_at": removedAt.UTC().Format(time.RFC3339Nano), "removed_fields": paths}
// Fixed-width numeric notation keeps the receipt size independent of the
// positive byte count's decimal width, including power-of-ten boundaries.
marker["removed_bytes"] = json.Number(strconv.FormatFloat(0, 'e', 8, 64))
markerJSON, err := json.Marshal(marker)
if err != nil {
return PayloadReduction{Metadata: metadata}, err
}
root[payloadRetentionKey] = markerJSON
encoded, err := json.Marshal(root)
if err != nil {
return PayloadReduction{Metadata: metadata}, err
}
delta := int64(len(metadata) - len(encoded))
if delta <= 0 {
return PayloadReduction{Metadata: metadata, Reason: "no_payload"}, nil
}
marker["removed_bytes"] = json.Number(strconv.FormatFloat(float64(delta), 'e', 8, 64))
markerJSON, err = json.Marshal(marker)
if err != nil {
return PayloadReduction{Metadata: metadata}, err
}
root[payloadRetentionKey] = markerJSON
encoded, err = json.Marshal(root)
if err != nil {
return PayloadReduction{Metadata: metadata}, err
}
delta = int64(len(metadata) - len(encoded))
if delta <= 0 {
return PayloadReduction{Metadata: metadata, Reason: "no_payload"}, nil
}
return PayloadReduction{Metadata: encoded, RemovedBytes: delta}, nil
}
func payloadObject(raw []byte) (rawPayloadObject, bool) {
var obj rawPayloadObject
err := json.Unmarshal(raw, &obj)
return obj, err == nil && obj != nil
}
func protectedPayload(norm rawPayloadObject) bool {
for _, key := range []string{"background_work", "monitor", "create_task", "subagent_task", "show_plan", "manage_todos"} {
if _, ok := norm[key]; ok {
return true
}
}
return false
}
func reducibleGeneric(body rawPayloadObject, result json.RawMessage) bool {
var name string
if json.Unmarshal(body["name"], &name) != nil || name == "" {
return false
}
// MCP control tools and provider plan/task tools have durable result contracts.
lower := strings.ToLower(name)
for _, word := range []string{"kandev", "plan", "todo", "task", "agent", "attachment", "resource", "canvas", "question", "permission"} {
if strings.Contains(lower, word) {
return false
}
}
return plainPayloadResult(body["output"]) && plainPayloadResult(result)
}
func plainPayloadResult(raw json.RawMessage) bool {
if len(raw) == 0 || string(raw) == "null" {
return true
}
var value string
if json.Unmarshal(raw, &value) != nil {
return false
}
// Providers can wrap structured MCP envelopes in result text.
var structured any
if json.Unmarshal([]byte(value), &structured) == nil {
switch structured.(type) {
case map[string]any, []any:
return false
}
}
return true
}
func removePayloadFields(kind string, body rawPayloadObject) ([]string, bool, error) {
prefix := "normalized." + kind + "."
switch kind {
case "generic":
paths, ok := removePayloadKeys(body, prefix, "input", "output")
return paths, ok, nil
case "http_request":
paths, ok := removePayloadKeys(body, prefix, "response")
return paths, ok, nil
case "modify_file":
return removeMutationPayloads(body, prefix)
default:
return removeOutputPayloads(kind, body, prefix)
}
}
func removePayloadKeys(obj rawPayloadObject, prefix string, keys ...string) ([]string, bool) {
var paths []string
for _, key := range keys {
raw, exists := obj[key]
if !exists {
continue
}
if key != "input" {
var target any = new(string)
if key == "files" {
target = new([]string)
}
if json.Unmarshal(raw, target) != nil {
return nil, false
}
}
delete(obj, key)
paths = append(paths, prefix+key)
}
return paths, true
}
func removeMutationPayloads(body rawPayloadObject, prefix string) ([]string, bool, error) {
raw, exists := body["mutations"]
if !exists {
return nil, true, nil
}
var mutations []rawPayloadObject
if json.Unmarshal(raw, &mutations) != nil {
return nil, false, nil
}
var paths []string
for _, mutation := range mutations {
if mutation == nil {
return nil, false, nil
}
removed, ok := removePayloadKeys(mutation, prefix+"mutations.*.", "content", "old_content", "new_content", "diff")
if !ok {
return nil, false, nil
}
paths = append(paths, removed...)
}
encoded, err := json.Marshal(mutations)
if err != nil {
return nil, false, err
}
body["mutations"] = encoded
return paths, true, nil
}
func removeOutputPayloads(kind string, body rawPayloadObject, prefix string) ([]string, bool, error) {
raw, exists := body["output"]
if !exists || string(raw) == "null" {
return nil, true, nil
}
output, ok := payloadObject(raw)
if !ok {
return nil, false, nil
}
fields := map[string][]string{"shell_exec": {"stdout", "stderr"}, "read_file": {"content"}, "code_search": {"files"}}
paths, ok := removePayloadKeys(output, prefix+"output.", fields[kind]...)
if !ok {
return nil, false, nil
}
encoded, err := json.Marshal(output)
if err != nil {
return nil, false, err
}
body["output"] = encoded
return paths, true, nil
}