Files
kamidso-back/kamidso/engine/app/openapi/api_v1/subagent/history.py
T
javadandCursor edbaa2acec Add native Didar connectors so chats can query CRM data reliably.
Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 19:26:47 +03:30

452 lines
16 KiB
Python

"""Bounded persistence helpers for parallel sub-agent execution history."""
import copy
import json
from typing import Any, Dict, List, MutableMapping, Optional
MAX_SUBAGENT_HISTORY_BYTES = 256 * 1024
_MAX_STEPS_PER_AGENT = 15
_MAX_CHUNKS_PER_STEP = 2
_MAX_NAME_CHARS = 512
_MAX_GOAL_CHARS = 4_000
_MAX_INTENTION_CHARS = 1_000
_MAX_SQL_CHARS = 8_000
_MAX_RESULT_CHARS = 8_000
_MAX_TEXT_CHUNK_CHARS = 2_000
_MAX_RETAINED_HTML_BYTES = 192 * 1024
_MAX_IMAGE_CHUNK_CHARS = 4_096
_MAX_ARTIFACTS_PER_AGENT = 32
_MAX_ARTIFACT_REF_CHARS = 4_096
_SIZE_SAFETY_BYTES = 1_024
_HTML_OMITTED = "HTML output omitted from history to keep this conversation compact."
_IMAGE_OMITTED = "Large inline image omitted from history; open the generated artifact."
_OUTPUT_OMITTED = "Large step output omitted from history."
def _bounded_text(value: Any, max_chars: int) -> Optional[str]:
if value is None:
return None
text = str(value)
if not text:
return None
if len(text) <= max_chars:
return text
return text[:max_chars] + "…"
def _coerce_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _bounded_content(value: Any, max_chars: int) -> Any:
if isinstance(value, str):
return _bounded_text(value, max_chars) or ""
try:
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError):
return _bounded_text(str(value), max_chars) or ""
if len(encoded) <= max_chars:
return copy.deepcopy(value)
return encoded[:max_chars] + "…"
def _copy_chunks(raw_chunks: Any) -> List[Dict[str, Any]]:
if not isinstance(raw_chunks, list):
return []
chunks: List[Dict[str, Any]] = []
for raw_chunk in raw_chunks[:_MAX_CHUNKS_PER_STEP]:
if not isinstance(raw_chunk, dict) or raw_chunk.get("content") is None:
continue
output_type = str(raw_chunk.get("output_type") or "text")
if output_type == "html":
content = raw_chunk.get("content")
# Keep HTML atomic: a truncated document cannot render reliably.
# The aggregate cache below bounds all retained HTML across agents.
if (
not isinstance(content, str)
or len(content.encode("utf-8")) > _MAX_RETAINED_HTML_BYTES
):
copied_chunk: Dict[str, Any] = {
"output_type": "text",
"content": _HTML_OMITTED,
}
else:
copied_chunk = {"output_type": "html", "content": content}
elif output_type == "image":
content = raw_chunk.get("content")
if (
not isinstance(content, str)
or content.startswith("data:")
or len(content) > _MAX_IMAGE_CHUNK_CHARS
):
copied_chunk = {
"output_type": "text",
"content": _IMAGE_OMITTED,
}
else:
copied_chunk = {"output_type": "image", "content": content}
else:
copied_chunk = {
"output_type": output_type,
"content": _bounded_content(
raw_chunk.get("content"),
_MAX_TEXT_CHUNK_CHARS,
),
}
title = _bounded_text(raw_chunk.get("title"), _MAX_NAME_CHARS)
if title:
copied_chunk["title"] = title
chunks.append(copied_chunk)
if len(raw_chunks) > _MAX_CHUNKS_PER_STEP:
chunks.append({"output_type": "text", "content": _OUTPUT_OMITTED})
return chunks
def _trim_retained_html(
state: MutableMapping[str, Dict[str, Any]],
) -> None:
"""Keep complete recent HTML documents within a small aggregate cache."""
html_chunks = []
total_bytes = 0
for agent in state.values():
for step in agent.get("steps") or []:
for chunk in step.get("chunks") or []:
if (
isinstance(chunk, dict)
and chunk.get("output_type") == "html"
and isinstance(chunk.get("content"), str)
):
content_bytes = len(chunk["content"].encode("utf-8"))
html_chunks.append((chunk, content_bytes))
total_bytes += content_bytes
for chunk, content_bytes in html_chunks:
if total_bytes <= _MAX_RETAINED_HTML_BYTES:
break
_replace_chunk(chunk, _HTML_OMITTED)
total_bytes -= content_bytes
def _new_agent(agent_id: str, event: Dict[str, Any]) -> Dict[str, Any]:
return {
"agent_id": agent_id,
"name": _bounded_text(
event.get("agent_name") or agent_id,
_MAX_NAME_CHARS,
)
or agent_id,
"status": "running",
"lane": _coerce_int(event.get("lane")),
"batch_id": _coerce_int(event.get("batch_id")),
"artifact_count": 0,
"steps": [],
}
def _copy_artifact(raw_artifact: Dict[str, Any]) -> Optional[Dict[str, str]]:
raw_url = raw_artifact.get("url")
if (
not isinstance(raw_url, str)
or raw_url.startswith("data:")
or len(raw_url) > _MAX_ARTIFACT_REF_CHARS
):
return None
if not raw_url:
return None
artifact = {
"type": _bounded_text(raw_artifact.get("type"), 32) or "file",
"url": raw_url,
}
title = _bounded_text(raw_artifact.get("title"), _MAX_NAME_CHARS)
if title:
artifact["title"] = title
return artifact
def update_subagent_history(
state: MutableMapping[str, Dict[str, Any]],
event: Dict[str, Any],
) -> None:
"""Merge one SSE lifecycle event into a persistable sub-agent snapshot."""
event_type = event.get("type")
if event_type == "subagent.artifacts":
for item in event.get("items") or []:
if not isinstance(item, dict) or not item.get("agent_id"):
continue
agent_id = str(item["agent_id"])
agent = state.setdefault(agent_id, _new_agent(agent_id, item))
agent["artifact_count"] = _coerce_int(agent.get("artifact_count")) + 1
artifact = _copy_artifact(item)
if artifact:
artifacts = agent.setdefault("artifacts", [])
if artifact not in artifacts:
if len(artifacts) < _MAX_ARTIFACTS_PER_AGENT:
artifacts.append(artifact)
else:
agent["artifacts_truncated"] = True
return
if event_type not in {"agent.start", "agent.step", "agent.done"}:
return
if not event.get("agent_id"):
return
agent_id = str(event["agent_id"])
agent = state.setdefault(agent_id, _new_agent(agent_id, event))
if event_type == "agent.start":
agent["name"] = (
_bounded_text(
event.get("agent_name") or agent.get("name") or agent_id,
_MAX_NAME_CHARS,
)
or agent_id
)
goal = _bounded_text(event.get("goal"), _MAX_GOAL_CHARS)
if goal:
agent["goal"] = goal
agent["lane"] = _coerce_int(event.get("lane"), agent.get("lane", 0))
agent["batch_id"] = _coerce_int(event.get("batch_id"), agent.get("batch_id", 0))
return
if event_type == "agent.step":
if event.get("status") == "running":
return
action = _bounded_text(event.get("action"), _MAX_NAME_CHARS)
if not action:
return
step: Dict[str, Any] = {"action": action}
intention = _bounded_text(event.get("intention"), _MAX_INTENTION_CHARS)
sql = _bounded_text(event.get("sql"), _MAX_SQL_CHARS)
chunks = _copy_chunks(event.get("chunks"))
if intention:
step["intention"] = intention
if sql:
step["sql"] = sql
if chunks:
step["chunks"] = chunks
steps = agent.setdefault("steps", [])
if len(steps) >= _MAX_STEPS_PER_AGENT:
steps.pop(0)
agent["history_truncated"] = True
steps.append(step)
_trim_retained_html(state)
return
status = str(event.get("status") or "done")
agent["status"] = (
status if status in {"running", "done", "timeout", "failed"} else "done"
)
result = _bounded_text(event.get("result"), _MAX_RESULT_CHARS)
if result:
agent["result"] = result
if event.get("elapsed_ms") is not None:
agent["elapsed_ms"] = max(0, _coerce_int(event.get("elapsed_ms")))
agent["batch_id"] = _coerce_int(event.get("batch_id"), agent.get("batch_id", 0))
def fail_running_subagent_history(
state: MutableMapping[str, Dict[str, Any]],
) -> None:
"""Finalize unfinished agents before persisting an interrupted lead run."""
for agent in state.values():
if agent.get("status") == "running":
agent["status"] = "failed"
agent.setdefault("result", "Execution was interrupted before completion.")
def _snapshot_size(snapshot: List[Dict[str, Any]]) -> int:
# Measure the incremental bytes using the same default JSON separators as
# both production serialization layers. The sentinel reproduces the comma
# added to an existing payload, while the safety margin covers surrounding
# view-message metadata.
base_payload = json.dumps({"_base": None}, ensure_ascii=False)
snapshot_payload = json.dumps(
{"_base": None, "sub_agents": snapshot},
ensure_ascii=False,
)
base_record = json.dumps({"content": base_payload}, ensure_ascii=False)
snapshot_record = json.dumps({"content": snapshot_payload}, ensure_ascii=False)
return (
len(snapshot_record.encode("utf-8"))
- len(base_record.encode("utf-8"))
+ _SIZE_SAFETY_BYTES
)
def _replace_chunk(chunk: Dict[str, Any], message: str) -> None:
chunk["output_type"] = "text"
chunk["content"] = message
def _nested_value_size(value: Any) -> int:
"""Measure one value after the same two JSON encodings used by history."""
serialized = json.dumps(value, ensure_ascii=False)
return len(json.dumps(serialized, ensure_ascii=False).encode("utf-8"))
def _latest_agent_suffix(
snapshot: List[Dict[str, Any]],
max_bytes: int,
) -> List[Dict[str, Any]]:
"""Retain the largest recent suffix that fits, using logarithmic probes."""
low = 0
high = len(snapshot)
best: List[Dict[str, Any]] = []
while low <= high:
count = (low + high) // 2
candidate = copy.deepcopy(snapshot[-count:]) if count else []
if candidate and count < len(snapshot):
candidate[0]["history_truncated"] = True
if _snapshot_size(candidate) <= max_bytes:
best = candidate
low = count + 1
else:
high = count - 1
return best
def build_subagent_history_snapshot(
state: MutableMapping[str, Dict[str, Any]],
max_bytes: int = MAX_SUBAGENT_HISTORY_BYTES,
) -> List[Dict[str, Any]]:
"""Return a deterministic snapshot compacted to a hard byte budget."""
if max_bytes <= 0:
return []
snapshot = copy.deepcopy(
sorted(
state.values(),
key=lambda item: (
_coerce_int(item.get("batch_id")),
_coerce_int(item.get("lane")),
str(item.get("agent_id") or ""),
),
)
)
snapshot_size = _snapshot_size(snapshot)
if snapshot_size <= max_bytes:
return snapshot
# First compact optional prose and ordinary output in one pass. SQL and
# complete HTML remain available for the execution-detail view.
for agent in snapshot:
for step in agent.get("steps") or []:
if step.get("sql"):
step["sql"] = _bounded_text(step["sql"], 2_000)
if step.get("intention"):
step["intention"] = _bounded_text(step["intention"], 500)
for chunk in step.get("chunks") or []:
if isinstance(chunk, dict) and chunk.get("output_type") not in {
"html",
"image",
}:
chunk["content"] = _bounded_content(
chunk.get("content"),
500,
)
if agent.get("goal"):
agent["goal"] = _bounded_text(agent["goal"], 1_000)
if agent.get("result"):
agent["result"] = _bounded_text(agent["result"], 1_000)
snapshot_size = _snapshot_size(snapshot)
if snapshot_size <= max_bytes:
return snapshot
# Ordinary chunks duplicate the dispatch summary and are the first detail
# to remove. Keep image references and complete HTML documents.
for agent in snapshot:
for step in agent.get("steps") or []:
chunks = [
chunk
for chunk in step.get("chunks") or []
if isinstance(chunk, dict)
and chunk.get("output_type") in {"html", "image"}
]
if chunks:
step["chunks"] = chunks
else:
step.pop("chunks", None)
snapshot_size = _snapshot_size(snapshot)
if snapshot_size <= max_bytes:
return snapshot
# Drop the oldest complete HTML documents only as needed. Estimate the
# cumulative reduction, then perform one exact snapshot measurement.
html_chunks = [
chunk
for agent in snapshot
for step in agent.get("steps") or []
for chunk in step.get("chunks") or []
if isinstance(chunk, dict)
and chunk.get("output_type") == "html"
and isinstance(chunk.get("content"), str)
]
required_reduction = snapshot_size - max_bytes
reduced_bytes = 0
for chunk in html_chunks:
content = chunk["content"]
reduced_bytes += max(
0,
_nested_value_size(content) - _nested_value_size(_HTML_OMITTED),
)
_replace_chunk(chunk, _HTML_OMITTED)
if reduced_bytes >= required_reduction:
break
if html_chunks:
snapshot_size = _snapshot_size(snapshot)
if snapshot_size <= max_bytes:
return snapshot
# Bound detailed traces uniformly instead of repeatedly serializing after
# every removed step.
for agent in snapshot:
steps = agent.get("steps") or []
if len(steps) > 5:
agent["steps"] = steps[-5:]
agent["history_truncated"] = True
snapshot_size = _snapshot_size(snapshot)
if snapshot_size <= max_bytes:
return snapshot
# Preserve one action (and a short SQL statement) for every agent before
# considering removal of whole agents.
for agent in snapshot:
agent["name"] = _bounded_text(agent.get("name"), 256) or str(
agent.get("agent_id") or "sub-agent"
)
agent.pop("goal", None)
agent.pop("result", None)
steps = agent.get("steps") or []
if steps:
step = steps[-1]
step["action"] = _bounded_text(step.get("action"), 256) or "unknown"
step.pop("intention", None)
step.pop("chunks", None)
if step.get("sql"):
step["sql"] = _bounded_text(step["sql"], 500)
agent["steps"] = [step]
agent["history_truncated"] = True
snapshot_size = _snapshot_size(snapshot)
if snapshot_size <= max_bytes:
return snapshot
# At very high configured concurrency, keep every task row even if its
# detailed trace no longer fits.
for agent in snapshot:
if agent.get("steps"):
agent["steps"] = []
agent["history_truncated"] = True
if _snapshot_size(snapshot) <= max_bytes:
return snapshot
# A pathological number of agents can still exceed the hard cap. Retain
# the most recent batches and mark the first retained row as truncated.
return _latest_agent_suffix(snapshot, max_bytes)