Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ call the shared `oddish.core.endpoints.deletion` helpers.

Public share links use 256-bit `public_token` values and are access-by-link, not
enumerable. The unauthenticated `/public/experiments` list intentionally returns
no share tokens. Public task/trial/file routes must stay scoped under
no share tokens. Public task/trial/live/file routes must stay scoped under
`/public/experiments/{public_token}/...` and verify membership in that shared
experiment; do not reintroduce `/public/tasks/{task_id}` or
`/public/trials/{trial_id}` ID-only access. Unpublishing an experiment clears
Expand Down
1 change: 1 addition & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ All routes require auth unless marked public.
| GET | `/public/experiments/{public_token}/tasks` | Public tasks and trials for a shared experiment |
| GET | `/public/experiments/{public_token}/tasks/{task_id}` | Public task status within a shared experiment |
| GET | `/public/experiments/{public_token}/tasks/{task_id}/trials` | Public trial list within a shared experiment |
| GET | `/public/experiments/{public_token}/trials/{trial_id}/live` | Public live transcript and running usage |
| GET | `/public/experiments/{public_token}/trials/{trial_id}/logs` | Public trial logs |
| GET | `/public/experiments/{public_token}/trials/{trial_id}/logs/structured` | Public structured logs |
| GET | `/public/experiments/{public_token}/trials/{trial_id}/trajectory` | Public trajectory |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,30 @@


class ExploreTrajectoryBlockTaxonomy(str, enum.Enum):
# These two groups are no longer only documentary: the prompt renders them
# as its own headings, so membership here is what the model is told the
# label means. Planning lives on this side because it is thinking about the
# work, not doing it -- PLAN_CORRECTION sits beside WRITING_PLAN so the two
# read as a pair.
READING_FILES = "reading_files"
THINKING_RECALL = "thinking_recall"
THINKING_UNDERSTAND = "thinking_understand"
THINKING_HYPOTHESIZE = "thinking_hypothesize"
WRITING_PLAN = "writing_plan"
PLAN_CORRECTION = "plan_correction"


class ImplementTrajectoryBlockTaxonomy(str, enum.Enum):
# Ordered as the work happens: plan, replan, build, correct, test, debug.
# PLAN_CORRECTION sits beside WRITING_PLAN so the model reads the two as a
# pair; the flat vocabulary below preserves this order in the prompt.
WRITING_PLAN = "writing_plan"
PLAN_CORRECTION = "plan_correction"
# Ordered as the work happens: build, correct, test, debug, report.
# WRITING_REPORT is last because it is what a run ends with.
IMPLEMENTING = "implementing"
IMPLEMENTING_CORRECTION = "implementing_correction"
WRITING_TESTS = "writing_tests"
TESTING_PUBLIC = "testing_public"
TESTING_CUSTOM = "testing_custom"
TESTING_EDGE_CASES = "testing_edge_cases"
DEBUGGING = "debugging"
WRITING_REPORT = "writing_report"


# One flat vocabulary built from the two sub-enums, so a component carries a
Expand Down Expand Up @@ -149,7 +154,11 @@ def __init__(
# ---- prompt sections (build_prompt is inherited) ----
def sections(self) -> list[dict]:
ti = self.trajectory_input
taxonomy_values = [m.value for m in TrajectoryBlockTaxonomy]
# Rendered per sub-enum, not from the flat vocabulary: the prompt shows
# the two groups, and both lists stay derived from the members so a new
# label cannot go missing from the prompt.
explore_values = [m.value for m in ExploreTrajectoryBlockTaxonomy]
implement_values = [m.value for m in ImplementTrajectoryBlockTaxonomy]
return [
{
"name": "preamble",
Expand Down Expand Up @@ -178,7 +187,7 @@ def sections(self) -> list[dict]:
"raw_input": {},
"schema": _InstructionsIn,
"formatter": lambda _d: tp.instructions_section(
self._instructions_template, taxonomy_values
self._instructions_template, explore_values, implement_values
),
},
{
Expand Down Expand Up @@ -209,9 +218,13 @@ def _fmt_outcome(d: _OutcomeIn) -> str:

@staticmethod
def _fmt_trajectory(d: _TrajectoryIn) -> str:
from api.services.summarize_trajectory import preprocess
from api.services.summarize_trajectory import drop_inert_steps, preprocess

return tp.trajectory_section(json.dumps(preprocess(d.trajectory)))
# Drop first: contentless steps are most of a trajectory, and there is
# no point truncating text on a step the model will never read.
return tp.trajectory_section(
json.dumps(preprocess(drop_inert_steps(d.trajectory)))
)

# ---- parsing (parse is inherited; this filters elements) ----
def _valid_step_ids(self) -> set[int]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,92 @@ def outcome_section(final_reward: str, verifier_output: str, model_used: str) ->
)


def instructions_section(template: str, taxonomy_values: list[str]) -> str:
# One phrase per label. The model gets no other guidance on what a label
# means, so this text is the whole definition. Keyed by value rather than
# enum member to keep this module free of the block's imports; every member
# must appear here or render_taxonomy raises.
TAXONOMY_DESCRIPTIONS: dict[str, str] = {
"reading_files": "opens, lists, or searches files to see what is there.",
"thinking_recall": (
"restates known facts, requirements, or findings from earlier in this run."
),
"thinking_understand": (
"works out how existing code or an observed failure actually behaves."
),
"thinking_hypothesize": (
"proposes a cause or an outcome that is not yet confirmed."
),
"writing_plan": (
"sets out intended work before that work is done. Forward-looking only."
),
"plan_correction": (
"abandons or materially changes a plan stated earlier in this run, and "
"adopts a different approach. Needs an earlier plan to revise."
),
"implementing": (
"writes or edits code, configuration, or files toward the solution."
),
"implementing_correction": (
"repairs the agent's own earlier edit, such as a compile error, a wrong "
"import, or a bad value."
),
"writing_tests": "adds or edits tests.",
"testing_public": "runs the task's provided tests or checker.",
"testing_custom": "runs tests or scripts that the agent wrote itself.",
"testing_edge_cases": "deliberately exercises boundary or unusual inputs.",
"debugging": (
"investigates a failure that already occurred, such as reading an error, "
"adding logging, or bisecting."
),
"writing_report": (
"reports on work already done, such as a status write-up, a hand-off "
"message, or a final claim that the task is complete. Backward-looking, "
"where `writing_plan` is forward-looking."
),
}

EXPLORE_HEADING = (
"THINKING / EXPLORING -- the agent is learning, and the solution does not change:"
)
IMPLEMENT_HEADING = (
"IMPLEMENTING / TESTING -- the agent is changing the solution or checking it:"
)


def render_taxonomy(explore_values: list[str], implement_values: list[str]) -> str:
"""Render the grouped, defined vocabulary the model chooses labels from.

Raises on a value with no description: a label the enum offers but the
prompt never defines is worse than a missing label, because the model
still has to use it and can only guess from the name.
"""
missing = [
v
for v in (*explore_values, *implement_values)
if v not in TAXONOMY_DESCRIPTIONS
]
if missing:
raise ValueError(f"taxonomy labels without a description: {missing}")

def block(heading: str, values: list[str]) -> str:
lines = [heading]
lines += [f"- `{v}`: {TAXONOMY_DESCRIPTIONS[v]}" for v in values]
return "\n".join(lines)

return (
block(EXPLORE_HEADING, explore_values)
+ "\n\n"
+ block(IMPLEMENT_HEADING, implement_values)
)


def instructions_section(
template: str, explore_values: list[str], implement_values: list[str]
) -> str:
# str.replace, not .format: the template body contains JSON braces.
return template.replace("{{taxonomy}}", ", ".join(taxonomy_values))
return template.replace(
"{{taxonomy}}", render_taxonomy(explore_values, implement_values)
)


def trajectory_section(trajectory_json: str) -> str:
Expand Down
78 changes: 78 additions & 0 deletions backend/api/services/summarize_trajectory.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,84 @@ def _process_observation(obs: dict | None) -> dict | None:
return new_obs


def _has_content(value: object) -> bool:
"""True when a MessageContent / ObservationContent carries substance.

Both are ``str | list[ContentPart] | None``, so the list form has to be
walked -- a step whose only substance is a content-part list is real. Mirror
of the frontend's ``hasContent``: a text part counts when non-blank, any
other part (an image) counts on its own.
"""
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, list):
return any(
_has_content(part.get("text"))
if isinstance(part, dict) and part.get("type") == "text"
else bool(part)
for part in value
)
return bool(value)


def _step_is_inert(step: dict) -> bool:
"""True when a step carries no content of any kind.

Agent protocols emit empty turns between real ones -- ``{"step_id": 3,
"source": "user", "message": ""}`` and the like. They are 71-88% of the
steps in every trajectory measured, in runs as long as 366 consecutive
steps, and they say nothing about what the agent did.

Deliberately conservative: any tool call, any non-blank message,
reasoning, or observation content keeps the step. The step-omission marker
from ``clip_trajectory_steps`` carries a message, so it survives too.
"""
if step.get("tool_calls"):
return False
if _has_content(step.get("message")):
return False
if _has_content(step.get("reasoning_content")):
return False

observation = step.get("observation")
if isinstance(observation, dict):
for result in observation.get("results") or []:
if isinstance(result, dict):
if _has_content(result.get("content")):
return False
elif result:
return False
elif observation:
return False
return True


def drop_inert_steps(trajectory: dict) -> dict:
"""Return a copy of ``trajectory`` without its contentless steps.

Applied only where the prompt is built, so it changes what the model reads
and nothing else: ``to_summary`` and ``_valid_step_ids`` both key off the
unfiltered ``TrajectoryInput.trajectory``, so durations, step indices, and
citation validation are unaffected. Surviving steps keep their original
``step_id`` -- nothing is renumbered -- so a cited id still resolves.

Steps the model never sees go unclaimed by any component, which the
frontend already renders through its synthetic "unattributed" bucket.
"""
steps = trajectory.get("steps") or []
kept = [s for s in steps if not (isinstance(s, dict) and _step_is_inert(s))]
if len(kept) == len(steps):
return trajectory
logger.info(
"trajectory summary: dropped %d/%d contentless steps",
len(steps) - len(kept),
len(steps),
)
out = dict(trajectory)
out["steps"] = kept
return out


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clip before inert-step drop

Medium Severity

Overflow retries in generate still clip by raw step count, while drop_inert_steps runs only later inside _fmt_trajectory. On long runs where the kept head and tail are mostly contentless, the retry prompt can collapse to little more than the omission marker after the contentful middle was discarded.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7549ca4. Configure here.

def preprocess(trajectory: dict) -> dict:
"""Return a copy of ``trajectory`` with images stripped and long text truncated."""
out = deepcopy(trajectory)
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_summarize_trajectory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
TRUNCATE_HEAD,
TRUNCATE_TAIL,
build_task_context,
drop_inert_steps,
get_or_generate_summary,
preprocess,
)
Expand All @@ -43,6 +44,86 @@ def _make_step(step_id: int, **overrides) -> dict:


# ---------------------------------------------------------------------------
# drop_inert_steps


def test_drop_inert_steps_removes_contentless_steps_and_keeps_step_ids():
"""The real shape: empty user turns between real agent steps."""
trajectory = {
"steps": [
{"step_id": 1, "source": "agent", "message": "looking at the pom"},
{"step_id": 2, "source": "user", "message": ""},
{"step_id": 3, "source": "user", "message": " "},
{"step_id": 4, "source": "agent", "message": "", "tool_calls": [{"a": 1}]},
{"step_id": 5, "source": "user", "message": ""},
]
}
out = drop_inert_steps(trajectory)
# Survivors keep their original ids -- nothing is renumbered, so a cited
# step_id still resolves against the unfiltered trajectory.
assert [s["step_id"] for s in out["steps"]] == [1, 4]
assert trajectory["steps"][1]["step_id"] == 2, "input must not be mutated"


def test_drop_inert_steps_keeps_observation_only_and_reasoning_only_steps():
trajectory = {
"steps": [
{
"step_id": 1,
"message": "",
"observation": {"results": [{"content": "BUILD FAILURE"}]},
},
{"step_id": 2, "message": "", "reasoning_content": "the pom is wrong"},
{"step_id": 3, "message": "", "observation": {"results": [{"content": ""}]}},
]
}
assert [s["step_id"] for s in drop_inert_steps(trajectory)["steps"]] == [1, 2]


def test_drop_inert_steps_keeps_the_clip_omission_marker():
"""clip_trajectory_steps' marker has no step_id; it must still survive."""
from api.services.summarize_trajectory import STEP_OMISSION_MARKER

trajectory = {
"steps": [
{"step_id": None, "source": "system", "message": STEP_OMISSION_MARKER.format(n=9)},
{"step_id": 7, "source": "user", "message": ""},
]
}
out = drop_inert_steps(trajectory)
assert len(out["steps"]) == 1
assert "9" in out["steps"][0]["message"]


def test_drop_inert_steps_returns_input_when_nothing_is_inert():
trajectory = {"steps": [{"step_id": 1, "message": "hi"}]}
assert drop_inert_steps(trajectory) is trajectory


def test_drop_inert_steps_keeps_content_part_lists():
"""``message``/``content`` are ``str | list[ContentPart]``.

Matches the frontend's ``hasContent``: a text part counts when non-blank,
and an image part counts on its own.
"""
trajectory = {
"steps": [
{"step_id": 1, "message": [{"type": "text", "text": "looking at the pom"}]},
{"step_id": 2, "message": [{"type": "image", "source": {"data": "..."}}]},
{
"step_id": 3,
"message": [],
"observation": {
"results": [{"content": [{"type": "text", "text": "BUILD FAILURE"}]}]
},
},
{"step_id": 4, "message": [{"type": "text", "text": " "}]},
{"step_id": 5, "message": [], "observation": {"results": [{"content": []}]}},
]
}
assert [s["step_id"] for s in drop_inert_steps(trajectory)["steps"]] == [1, 2, 3]


# preprocess
# ---------------------------------------------------------------------------

Expand Down
Loading
Loading