Adjust a ragged row and an unreadable colour rather than throwing the file away, and notice a tool call that ran out of room
Browse files- distinct_agent/harness.py +55 -1
- distinct_skills/handlers.py +86 -22
- tests/test_harness_makes_files.py +69 -0
- tests/test_ragged_rows_still_make_a_table.py +173 -0
distinct_agent/harness.py
CHANGED
|
@@ -163,6 +163,8 @@ class StructuredToolHarness:
|
|
| 163 |
artifacts: list[Mapping[str, Any]] = []
|
| 164 |
model_usages: list[Mapping[str, Any]] = []
|
| 165 |
nudged = False
|
|
|
|
|
|
|
| 166 |
|
| 167 |
# Two spare turns beyond the tool budget: one for the final answer, and
|
| 168 |
# one for the single correction below. The loop still cannot run away,
|
|
@@ -193,6 +195,14 @@ class StructuredToolHarness:
|
|
| 193 |
)
|
| 194 |
model_usages.append(dict(inference.usage))
|
| 195 |
action = _parse_action(inference.text, self.max_action_characters)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
answered_without_acting = action is None or action["type"] == "final"
|
| 197 |
if answered_without_acting and makers and not events and not nudged:
|
| 198 |
# Finished on the first move, when a tool here makes files.
|
|
@@ -208,7 +218,15 @@ class StructuredToolHarness:
|
|
| 208 |
if action is None:
|
| 209 |
progress(1.0, "Local inference complete")
|
| 210 |
return HarnessResult(
|
| 211 |
-
text=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
usage=_usage(self.name, model_usages, events),
|
| 213 |
tool_events=tuple(events),
|
| 214 |
artifacts=tuple(artifacts),
|
|
@@ -1085,6 +1103,42 @@ def _initial_tool_prompt(prompt: str, manifest: tuple[Mapping[str, Any], ...]) -
|
|
| 1085 |
)
|
| 1086 |
|
| 1087 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
def _nudge_prompt(previous: str, makers: tuple[str, ...]) -> str:
|
| 1089 |
"""Asked for a file, wrote prose. Say so once, and ask again.
|
| 1090 |
|
|
|
|
| 163 |
artifacts: list[Mapping[str, Any]] = []
|
| 164 |
model_usages: list[Mapping[str, Any]] = []
|
| 165 |
nudged = False
|
| 166 |
+
shortened = False
|
| 167 |
+
ran_out_of_room = False
|
| 168 |
|
| 169 |
# Two spare turns beyond the tool budget: one for the final answer, and
|
| 170 |
# one for the single correction below. The loop still cannot run away,
|
|
|
|
| 195 |
)
|
| 196 |
model_usages.append(dict(inference.usage))
|
| 197 |
action = _parse_action(inference.text, self.max_action_characters)
|
| 198 |
+
if action is None and was_truncated(inference.usage):
|
| 199 |
+
# Not prose: a call that ran past the output cap. Say so, and
|
| 200 |
+
# ask for a smaller one rather than accepting the fragment.
|
| 201 |
+
ran_out_of_room = True
|
| 202 |
+
if not shortened:
|
| 203 |
+
shortened = True
|
| 204 |
+
current_prompt = _too_long_prompt(current_prompt)
|
| 205 |
+
continue
|
| 206 |
answered_without_acting = action is None or action["type"] == "final"
|
| 207 |
if answered_without_acting and makers and not events and not nudged:
|
| 208 |
# Finished on the first move, when a tool here makes files.
|
|
|
|
| 218 |
if action is None:
|
| 219 |
progress(1.0, "Local inference complete")
|
| 220 |
return HarnessResult(
|
| 221 |
+
text=(
|
| 222 |
+
inference.text
|
| 223 |
+
+ "\n\n[This run's last tool call was cut off before it "
|
| 224 |
+
"finished, so nothing was produced by it. The request "
|
| 225 |
+
"needs more output room than this model has, or fewer "
|
| 226 |
+
"things asked for at once.]"
|
| 227 |
+
if ran_out_of_room
|
| 228 |
+
else inference.text
|
| 229 |
+
),
|
| 230 |
usage=_usage(self.name, model_usages, events),
|
| 231 |
tool_events=tuple(events),
|
| 232 |
artifacts=tuple(artifacts),
|
|
|
|
| 1103 |
)
|
| 1104 |
|
| 1105 |
|
| 1106 |
+
#: What llama-server calls a reply that ran out of room rather than finishing.
|
| 1107 |
+
_RAN_OUT_OF_ROOM = {"length", "limit", "max_tokens"}
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
def was_truncated(usage: Mapping[str, Any]) -> bool:
|
| 1111 |
+
"""Whether the model stopped because it hit the output cap, not because it finished."""
|
| 1112 |
+
|
| 1113 |
+
return str(usage.get("stop_reason") or "").casefold() in _RAN_OUT_OF_ROOM
|
| 1114 |
+
|
| 1115 |
+
|
| 1116 |
+
def _too_long_prompt(previous: str) -> str:
|
| 1117 |
+
"""The last action was cut off mid-JSON. Ask for a smaller one.
|
| 1118 |
+
|
| 1119 |
+
A TRUNCATED TOOL CALL LOOKS EXACTLY LIKE PROSE, AND THAT COST A WORKLOAD.
|
| 1120 |
+
|
| 1121 |
+
Held to the action schema, the model emitted a perfectly correct call --
|
| 1122 |
+
and the call was a whole recipe book in one argument, so it ran past the
|
| 1123 |
+
output cap and stopped mid-string. What arrived was not valid JSON, so it
|
| 1124 |
+
parsed as prose, so the run finished with a paragraph and no file, and the
|
| 1125 |
+
result said "produced 0 files" as though the model had ignored the tool.
|
| 1126 |
+
The two failures need opposite fixes and looked identical.
|
| 1127 |
+
|
| 1128 |
+
The reply itself says which happened -- ``finish_reason`` is ``length``
|
| 1129 |
+
rather than ``stop`` -- so it is used, and the model is told the thing it
|
| 1130 |
+
can act on: make a smaller call.
|
| 1131 |
+
"""
|
| 1132 |
+
|
| 1133 |
+
return (
|
| 1134 |
+
f"{previous}\n\n"
|
| 1135 |
+
"YOUR LAST ACTION WAS CUT OFF because it was too long to finish, so it\n"
|
| 1136 |
+
"did nothing. Make a smaller call: put less in each argument, and if the\n"
|
| 1137 |
+
"request needs a long document, produce it in several calls rather than\n"
|
| 1138 |
+
"one. Reply with ONE JSON object and nothing else."
|
| 1139 |
+
)
|
| 1140 |
+
|
| 1141 |
+
|
| 1142 |
def _nudge_prompt(previous: str, makers: tuple[str, ...]) -> str:
|
| 1143 |
"""Asked for a file, wrote prose. Say so once, and ask again.
|
| 1144 |
|
distinct_skills/handlers.py
CHANGED
|
@@ -330,15 +330,12 @@ def csv_table(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
|
|
| 330 |
guarded_header.append(safe)
|
| 331 |
|
| 332 |
rows: list[list[str]] = []
|
|
|
|
| 333 |
for number, line in lines[1:]:
|
| 334 |
cells = [cell.strip() for cell in line.split(separator)]
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
# the caller can fix the one line rather than guess.
|
| 339 |
-
raise SkillInputError(
|
| 340 |
-
f"line {number} has {len(cells)} fields but the header has {len(header)}"
|
| 341 |
-
)
|
| 342 |
row: list[str] = []
|
| 343 |
for cell in cells:
|
| 344 |
safe, changed = _guard(cell)
|
|
@@ -357,6 +354,9 @@ def csv_table(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
|
|
| 357 |
# Reported rather than hidden: a guarded cell is a cell whose text was
|
| 358 |
# changed, and the user is entitled to know their data was edited.
|
| 359 |
"guarded_cells": guarded,
|
|
|
|
|
|
|
|
|
|
| 360 |
"artifact": text_artifact(
|
| 361 |
_filename(arguments.get("filename"), "table.csv", ".csv"), "text/csv", text
|
| 362 |
),
|
|
@@ -368,6 +368,44 @@ def csv_table(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
|
|
| 368 |
# --------------------------------------------------------------------------
|
| 369 |
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
def _multiline(arguments: Mapping[str, object], name: str, *, limit: int) -> str:
|
| 372 |
"""Like ``_line`` but newlines are structure here, so they survive.
|
| 373 |
|
|
@@ -432,7 +470,7 @@ def _zip_bytes(parts: Mapping[str, str]) -> bytes:
|
|
| 432 |
_SEPARATORS: Mapping[str, str] = {"pipe": "|", "tab": "\t", "semicolon": ";"}
|
| 433 |
|
| 434 |
|
| 435 |
-
def _rows_from_text(arguments: Mapping[str, object]) -> list[list[str]]:
|
| 436 |
"""Header-then-rows lines, split on the named separator, shape-checked.
|
| 437 |
|
| 438 |
The same input convention as ``csv_table`` and for the same reason: a
|
|
@@ -450,13 +488,12 @@ def _rows_from_text(arguments: Mapping[str, object]) -> list[list[str]]:
|
|
| 450 |
raise SkillInputError("text needs a header line and at least one row")
|
| 451 |
rows = [[cell.strip() for cell in line.split(separator)] for line in lines]
|
| 452 |
width = len(rows[0])
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
return rows
|
| 460 |
|
| 461 |
|
| 462 |
_DOCX_CONTENT_TYPES = (
|
|
@@ -607,7 +644,7 @@ def spreadsheet_xlsx(arguments: Mapping[str, object], skill: SkillDefinition) ->
|
|
| 607 |
|
| 608 |
del skill
|
| 609 |
_known(arguments, frozenset({"text", "separator", "filename"}))
|
| 610 |
-
rows = _rows_from_text(arguments)
|
| 611 |
body: list[str] = []
|
| 612 |
for row_index, row in enumerate(rows, start=1):
|
| 613 |
cells = []
|
|
@@ -631,11 +668,14 @@ def spreadsheet_xlsx(arguments: Mapping[str, object], skill: SkillDefinition) ->
|
|
| 631 |
}
|
| 632 |
)
|
| 633 |
return {
|
|
|
|
|
|
|
|
|
|
| 634 |
"artifact": bytes_artifact(
|
| 635 |
_filename(arguments.get("filename"), "table.xlsx", ".xlsx"),
|
| 636 |
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 637 |
payload,
|
| 638 |
-
)
|
| 639 |
}
|
| 640 |
|
| 641 |
|
|
@@ -931,21 +971,42 @@ def theme_css(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
|
|
| 931 |
colors_text = _multiline(arguments, "colors", limit=1200)
|
| 932 |
heading = str(arguments.get("heading_font") or "Georgia, serif").strip()[:80]
|
| 933 |
body_font = str(arguments.get("body_font") or "Arial, sans-serif").strip()[:80]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
variables = []
|
|
|
|
| 935 |
for line in colors_text.splitlines():
|
| 936 |
line = line.strip()
|
| 937 |
if not line:
|
| 938 |
continue
|
| 939 |
key, _, value = line.partition(":")
|
| 940 |
-
key = key.strip().lower()
|
| 941 |
value = value.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
if not re.fullmatch(r"[a-z][a-z0-9-]{0,30}", key):
|
| 943 |
-
|
|
|
|
| 944 |
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value):
|
| 945 |
-
|
|
|
|
| 946 |
variables.append(f" --{key}: {value};")
|
| 947 |
if not variables:
|
| 948 |
-
raise SkillInputError(
|
|
|
|
|
|
|
|
|
|
| 949 |
sheet = (
|
| 950 |
f"/* Theme: {theme_name} — generated by distinct */\n"
|
| 951 |
":root {\n" + "\n".join(variables) + "\n"
|
|
@@ -956,9 +1017,12 @@ def theme_css(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
|
|
| 956 |
"h1, h2, h3, h4 { font-family: var(--font-heading); }\n"
|
| 957 |
)
|
| 958 |
return {
|
|
|
|
|
|
|
|
|
|
| 959 |
"artifact": text_artifact(
|
| 960 |
_filename(arguments.get("filename"), "theme.css", ".css"), "text/css", sheet
|
| 961 |
-
)
|
| 962 |
}
|
| 963 |
|
| 964 |
|
|
|
|
| 330 |
guarded_header.append(safe)
|
| 331 |
|
| 332 |
rows: list[list[str]] = []
|
| 333 |
+
adjusted: list[str] = []
|
| 334 |
for number, line in lines[1:]:
|
| 335 |
cells = [cell.strip() for cell in line.split(separator)]
|
| 336 |
+
cells, note = fit_row(cells, len(header), number)
|
| 337 |
+
if note:
|
| 338 |
+
adjusted.append(note)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
row: list[str] = []
|
| 340 |
for cell in cells:
|
| 341 |
safe, changed = _guard(cell)
|
|
|
|
| 354 |
# Reported rather than hidden: a guarded cell is a cell whose text was
|
| 355 |
# changed, and the user is entitled to know their data was edited.
|
| 356 |
"guarded_cells": guarded,
|
| 357 |
+
# Same reasoning, same place: a row that did not fit the header was
|
| 358 |
+
# made to fit, and every one of them is named here.
|
| 359 |
+
"adjusted_rows": adjusted,
|
| 360 |
"artifact": text_artifact(
|
| 361 |
_filename(arguments.get("filename"), "table.csv", ".csv"), "text/csv", text
|
| 362 |
),
|
|
|
|
| 368 |
# --------------------------------------------------------------------------
|
| 369 |
|
| 370 |
|
| 371 |
+
def fit_row(cells: list[str], width: int, number: int) -> tuple[list[str], str]:
|
| 372 |
+
"""Make one row the header's width, and say what was done to it.
|
| 373 |
+
|
| 374 |
+
REFUSING THE WHOLE TABLE OVER ONE RAGGED LINE MEANT NO TABLE AT ALL.
|
| 375 |
+
|
| 376 |
+
The rule here was: a row whose field count does not match the header is an
|
| 377 |
+
error, because padding it would invent data and silently change what the
|
| 378 |
+
user is told their table contains. The instinct is right and the second
|
| 379 |
+
half is the important part -- *silently*. What it produced in practice,
|
| 380 |
+
with a model doing the typing, was a run that called the tool, got a
|
| 381 |
+
refusal about line seven, and ended with no spreadsheet: two of twenty
|
| 382 |
+
benchmark workloads, both of them asking for a table.
|
| 383 |
+
|
| 384 |
+
Nothing is invented and nothing is hidden. A short row is padded with
|
| 385 |
+
empty cells; a long one keeps every field, with the surplus joined into
|
| 386 |
+
the last column so no text is dropped. Both come back with a sentence
|
| 387 |
+
naming the line and what happened to it, which the caller reports beside
|
| 388 |
+
the table, so a person reading it can go and look at line seven exactly as
|
| 389 |
+
they could before -- with the difference that they also have their table.
|
| 390 |
+
"""
|
| 391 |
+
|
| 392 |
+
if len(cells) == width:
|
| 393 |
+
return cells, ""
|
| 394 |
+
if len(cells) < width:
|
| 395 |
+
missing = width - len(cells)
|
| 396 |
+
return (
|
| 397 |
+
cells + [""] * missing,
|
| 398 |
+
f"line {number} had {len(cells)} fields where the header has {width}; "
|
| 399 |
+
f"{missing} empty cell(s) were added at the end",
|
| 400 |
+
)
|
| 401 |
+
kept = cells[: width - 1] + [" ".join(cells[width - 1 :])]
|
| 402 |
+
return (
|
| 403 |
+
kept,
|
| 404 |
+
f"line {number} had {len(cells)} fields where the header has {width}; "
|
| 405 |
+
"the surplus was joined into the last column",
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
def _multiline(arguments: Mapping[str, object], name: str, *, limit: int) -> str:
|
| 410 |
"""Like ``_line`` but newlines are structure here, so they survive.
|
| 411 |
|
|
|
|
| 470 |
_SEPARATORS: Mapping[str, str] = {"pipe": "|", "tab": "\t", "semicolon": ";"}
|
| 471 |
|
| 472 |
|
| 473 |
+
def _rows_from_text(arguments: Mapping[str, object]) -> tuple[list[list[str]], list[str]]:
|
| 474 |
"""Header-then-rows lines, split on the named separator, shape-checked.
|
| 475 |
|
| 476 |
The same input convention as ``csv_table`` and for the same reason: a
|
|
|
|
| 488 |
raise SkillInputError("text needs a header line and at least one row")
|
| 489 |
rows = [[cell.strip() for cell in line.split(separator)] for line in lines]
|
| 490 |
width = len(rows[0])
|
| 491 |
+
adjusted: list[str] = []
|
| 492 |
+
for index in range(1, len(rows)):
|
| 493 |
+
rows[index], note = fit_row(rows[index], width, index + 1)
|
| 494 |
+
if note:
|
| 495 |
+
adjusted.append(note)
|
| 496 |
+
return rows, adjusted
|
|
|
|
| 497 |
|
| 498 |
|
| 499 |
_DOCX_CONTENT_TYPES = (
|
|
|
|
| 644 |
|
| 645 |
del skill
|
| 646 |
_known(arguments, frozenset({"text", "separator", "filename"}))
|
| 647 |
+
rows, adjusted = _rows_from_text(arguments)
|
| 648 |
body: list[str] = []
|
| 649 |
for row_index, row in enumerate(rows, start=1):
|
| 650 |
cells = []
|
|
|
|
| 668 |
}
|
| 669 |
)
|
| 670 |
return {
|
| 671 |
+
# Named, never silent: a row that did not fit the header was made to
|
| 672 |
+
# fit, and the caller is told which and how.
|
| 673 |
+
"adjusted_rows": adjusted,
|
| 674 |
"artifact": bytes_artifact(
|
| 675 |
_filename(arguments.get("filename"), "table.xlsx", ".xlsx"),
|
| 676 |
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 677 |
payload,
|
| 678 |
+
),
|
| 679 |
}
|
| 680 |
|
| 681 |
|
|
|
|
| 971 |
colors_text = _multiline(arguments, "colors", limit=1200)
|
| 972 |
heading = str(arguments.get("heading_font") or "Georgia, serif").strip()[:80]
|
| 973 |
body_font = str(arguments.get("body_font") or "Arial, sans-serif").strip()[:80]
|
| 974 |
+
# ONE BAD COLOUR LINE USED TO COST THE WHOLE THEME.
|
| 975 |
+
#
|
| 976 |
+
# "Primary: #1a1a2e" has a capital P and was refused; so was
|
| 977 |
+
# "background: rgb(20,20,30)". Either one ended the run with no stylesheet
|
| 978 |
+
# at all, which is how a benchmark workload asking for a theme file failed
|
| 979 |
+
# three calls in a row. A name that only needs normalising is normalised,
|
| 980 |
+
# a value that cannot be read is skipped and named, and the sheet is only
|
| 981 |
+
# refused when there is nothing left to put in it. Same rule as the ragged
|
| 982 |
+
# table rows: adjust, never silently.
|
| 983 |
variables = []
|
| 984 |
+
skipped: list[str] = []
|
| 985 |
for line in colors_text.splitlines():
|
| 986 |
line = line.strip()
|
| 987 |
if not line:
|
| 988 |
continue
|
| 989 |
key, _, value = line.partition(":")
|
| 990 |
+
key = re.sub(r"[\s_]+", "-", key.strip().lower()).strip("-")
|
| 991 |
value = value.strip()
|
| 992 |
+
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value) and re.fullmatch(
|
| 993 |
+
r"[0-9A-Fa-f]{6}|[0-9A-Fa-f]{3}", value
|
| 994 |
+
):
|
| 995 |
+
# A hex value with the hash left off is the commonest near-miss and
|
| 996 |
+
# is unambiguous, so it is completed rather than discarded.
|
| 997 |
+
value = f"#{value}"
|
| 998 |
if not re.fullmatch(r"[a-z][a-z0-9-]{0,30}", key):
|
| 999 |
+
skipped.append(f"{key or line!r}: not a usable colour name")
|
| 1000 |
+
continue
|
| 1001 |
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value):
|
| 1002 |
+
skipped.append(f"{key}: {value!r} is not a #hex value")
|
| 1003 |
+
continue
|
| 1004 |
variables.append(f" --{key}: {value};")
|
| 1005 |
if not variables:
|
| 1006 |
+
raise SkillInputError(
|
| 1007 |
+
"colors needs at least one 'name: #hex' line"
|
| 1008 |
+
+ (f"; none was usable ({'; '.join(skipped[:4])})" if skipped else "")
|
| 1009 |
+
)
|
| 1010 |
sheet = (
|
| 1011 |
f"/* Theme: {theme_name} — generated by distinct */\n"
|
| 1012 |
":root {\n" + "\n".join(variables) + "\n"
|
|
|
|
| 1017 |
"h1, h2, h3, h4 { font-family: var(--font-heading); }\n"
|
| 1018 |
)
|
| 1019 |
return {
|
| 1020 |
+
# Named, never silent: a colour line that could not be read was left
|
| 1021 |
+
# out, and the caller is told which and why.
|
| 1022 |
+
"skipped_colours": skipped,
|
| 1023 |
"artifact": text_artifact(
|
| 1024 |
_filename(arguments.get("filename"), "theme.css", ".css"), "text/css", sheet
|
| 1025 |
+
),
|
| 1026 |
}
|
| 1027 |
|
| 1028 |
|
tests/test_harness_makes_files.py
CHANGED
|
@@ -493,3 +493,72 @@ def test_a_follow_up_question_is_not_forced_to_make_a_file(tmp_path) -> None:
|
|
| 493 |
progress=lambda fraction, message: None,
|
| 494 |
)
|
| 495 |
assert "anyOf" in schemas[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
progress=lambda fraction, message: None,
|
| 494 |
)
|
| 495 |
assert "anyOf" in schemas[0]
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
# -- a call that ran out of room ---------------------------------------------
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
class _Truncating:
|
| 502 |
+
"""A model whose first action is cut off mid-JSON, as a real one is."""
|
| 503 |
+
|
| 504 |
+
def __init__(self, *outputs: str) -> None:
|
| 505 |
+
self._outputs = list(outputs)
|
| 506 |
+
self.prompts: list[str] = []
|
| 507 |
+
|
| 508 |
+
def run(self, model, prompt, *, cancel_event=None, progress=None, limits=None):
|
| 509 |
+
self.prompts.append(prompt)
|
| 510 |
+
text = self._outputs.pop(0) if self._outputs else '{"type":"final","answer":"done"}'
|
| 511 |
+
cut = text.startswith("{") and not text.rstrip().endswith("}")
|
| 512 |
+
from distinct_agent.runners import InferenceResult
|
| 513 |
+
|
| 514 |
+
return InferenceResult(
|
| 515 |
+
text=text,
|
| 516 |
+
usage={"runner": "fake", "stop_reason": "length" if cut else "stop"},
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def test_a_cut_off_call_is_recognised_as_one() -> None:
|
| 521 |
+
from distinct_agent.harness import was_truncated
|
| 522 |
+
|
| 523 |
+
assert was_truncated({"stop_reason": "length"})
|
| 524 |
+
assert not was_truncated({"stop_reason": "stop"})
|
| 525 |
+
assert not was_truncated({})
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def test_a_cut_off_call_is_asked_for_again_smaller(tmp_path) -> None:
|
| 529 |
+
"""It looks exactly like prose, and the two need opposite fixes."""
|
| 530 |
+
|
| 531 |
+
runner = _Truncating(
|
| 532 |
+
'{"type":"tool","tool":"create-deck@1","arguments":{"slides":"a very long ',
|
| 533 |
+
json.dumps({"type": "tool", "tool": "create-deck@1", "arguments": {"slides": "A :: b"}}),
|
| 534 |
+
json.dumps({"type": "final", "answer": "deck built"}),
|
| 535 |
+
)
|
| 536 |
+
broker = _Broker((DECK,))
|
| 537 |
+
result = _run(StructuredToolHarness(), _job(), runner, broker, tmp_path)
|
| 538 |
+
assert "CUT OFF" in runner.prompts[1]
|
| 539 |
+
assert broker.calls and result.text == "deck built"
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def test_a_run_that_kept_running_out_of_room_says_so(tmp_path) -> None:
|
| 543 |
+
""""produced 0 files" hid the reason; the answer now carries it."""
|
| 544 |
+
|
| 545 |
+
fragment = '{"type":"tool","tool":"create-deck@1","arguments":{"slides":"still going '
|
| 546 |
+
result = _run(
|
| 547 |
+
StructuredToolHarness(),
|
| 548 |
+
_job(),
|
| 549 |
+
_Truncating(fragment, fragment, fragment),
|
| 550 |
+
_Broker((DECK,)),
|
| 551 |
+
tmp_path,
|
| 552 |
+
)
|
| 553 |
+
assert "cut off" in result.text
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def test_a_finished_answer_is_not_treated_as_cut_off(tmp_path) -> None:
|
| 557 |
+
result = _run(
|
| 558 |
+
StructuredToolHarness(),
|
| 559 |
+
_job(),
|
| 560 |
+
_Truncating(json.dumps({"type": "final", "answer": "all done"})),
|
| 561 |
+
_Broker((CALCULATOR,)),
|
| 562 |
+
tmp_path,
|
| 563 |
+
)
|
| 564 |
+
assert result.text == "all done"
|
tests/test_ragged_rows_still_make_a_table.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One bad line used to cost the whole spreadsheet, and it cost two workloads.
|
| 2 |
+
|
| 3 |
+
The rule was: a row whose field count does not match the header is an error,
|
| 4 |
+
because padding it would invent data and silently change what the user is told
|
| 5 |
+
their table contains. The instinct is right; the load-bearing word is
|
| 6 |
+
*silently*. With a model doing the typing, what it produced was a run that
|
| 7 |
+
called the tool, got a refusal naming line seven, and finished with no file at
|
| 8 |
+
all -- twice in twenty benchmark workloads, both of them asking for a table.
|
| 9 |
+
|
| 10 |
+
So the row is made to fit and every adjustment is named in the result. Nothing
|
| 11 |
+
is invented, nothing is dropped, nothing is hidden, and the person gets their
|
| 12 |
+
table.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import base64
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
from distinct_skills.handlers import csv_table, fit_row, spreadsheet_xlsx
|
| 22 |
+
from distinct_skills.loader import SkillInputError
|
| 23 |
+
|
| 24 |
+
HEADER = "name|qty|aisle"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _csv(text: str) -> tuple[str, list[str]]:
|
| 28 |
+
result = csv_table({"text": text}, None)
|
| 29 |
+
return base64.b64decode(result["artifact"]["base64"]).decode(), result["adjusted_rows"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# -- the adjustment itself ---------------------------------------------------
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_a_row_that_already_fits_is_left_exactly_as_it_is() -> None:
|
| 36 |
+
assert fit_row(["a", "b", "c"], 3, 2) == (["a", "b", "c"], "")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_a_short_row_is_padded_and_the_padding_is_named() -> None:
|
| 40 |
+
cells, note = fit_row(["a", "b"], 3, 7)
|
| 41 |
+
assert cells == ["a", "b", ""]
|
| 42 |
+
assert "line 7" in note and "empty cell" in note
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_a_long_row_keeps_every_field_it_was_given() -> None:
|
| 46 |
+
"""Dropping the surplus would lose the user's text, which is worse."""
|
| 47 |
+
|
| 48 |
+
cells, note = fit_row(["a", "b", "c", "d"], 3, 4)
|
| 49 |
+
assert cells == ["a", "b", "c d"]
|
| 50 |
+
assert "line 4" in note and "surplus" in note
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_nothing_is_ever_invented() -> None:
|
| 54 |
+
"""Padding adds empty cells. It never guesses a value."""
|
| 55 |
+
|
| 56 |
+
cells, _note = fit_row(["a"], 4, 2)
|
| 57 |
+
assert cells == ["a", "", "", ""]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# -- through the tools -------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_a_ragged_line_no_longer_costs_the_whole_csv() -> None:
|
| 64 |
+
text, adjusted = _csv(f"{HEADER}\nflour|500 g|baking\nsalt|10 g\nsaffron|1 g|spices|extra")
|
| 65 |
+
assert "flour,500 g,baking" in text
|
| 66 |
+
assert "salt,10 g," in text
|
| 67 |
+
assert "saffron,1 g,spices extra" in text
|
| 68 |
+
assert len(adjusted) == 2
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_every_adjustment_names_the_line_it_happened_on() -> None:
|
| 72 |
+
"""So a person can go and look at line seven exactly as they could before."""
|
| 73 |
+
|
| 74 |
+
_text, adjusted = _csv(f"{HEADER}\na|b|c\nd|e")
|
| 75 |
+
assert adjusted and adjusted[0].startswith("line 3 ")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_a_table_that_needed_no_adjustment_reports_none() -> None:
|
| 79 |
+
_text, adjusted = _csv(f"{HEADER}\na|b|c\nd|e|f")
|
| 80 |
+
assert adjusted == []
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_the_spreadsheet_reports_its_adjustments_too() -> None:
|
| 84 |
+
result = spreadsheet_xlsx({"text": f"{HEADER}\na|b|c\nd|e"}, None)
|
| 85 |
+
assert len(result["adjusted_rows"]) == 1
|
| 86 |
+
assert result["artifact"]["size_bytes"] > 0
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# -- what is still refused ---------------------------------------------------
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_a_table_with_no_rows_is_still_refused() -> None:
|
| 93 |
+
"""There is no row to make fit, so there is nothing to produce."""
|
| 94 |
+
|
| 95 |
+
with pytest.raises(SkillInputError):
|
| 96 |
+
csv_table({"text": HEADER}, None)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_a_header_with_no_separator_is_still_refused() -> None:
|
| 100 |
+
"""A one-column table is almost always a caller who used the wrong separator."""
|
| 101 |
+
|
| 102 |
+
with pytest.raises(SkillInputError):
|
| 103 |
+
csv_table({"text": "name qty aisle\na b c"}, None)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_a_cell_that_would_run_as_a_formula_is_still_guarded() -> None:
|
| 107 |
+
"""Making rows fit must not have loosened the thing that stops a cell running."""
|
| 108 |
+
|
| 109 |
+
text, _adjusted = _csv(f"{HEADER}\n=cmd()|1|x")
|
| 110 |
+
assert "'=cmd()" in text
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_the_joined_cell_is_guarded_because_joining_happens_first() -> None:
|
| 114 |
+
"""Order matters: rows are made to fit, and *then* every cell is checked."""
|
| 115 |
+
|
| 116 |
+
text, adjusted = _csv(f"{HEADER}\na|b|=cmd()|tail")
|
| 117 |
+
assert adjusted
|
| 118 |
+
assert "'=cmd() tail" in text
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# -- the same rule, applied to a palette -------------------------------------
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _theme(colors: str):
|
| 125 |
+
from distinct_skills.handlers import theme_css
|
| 126 |
+
|
| 127 |
+
result = theme_css({"name": "Night Desk", "colors": colors}, None)
|
| 128 |
+
return base64.b64decode(result["artifact"]["base64"]).decode(), result["skipped_colours"]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_a_capitalised_colour_name_is_normalised_rather_than_refused() -> None:
|
| 132 |
+
""""Primary: #1a1a2e" used to end the run with no stylesheet."""
|
| 133 |
+
|
| 134 |
+
sheet, skipped = _theme("Primary: #1a1a2e")
|
| 135 |
+
assert "--primary: #1a1a2e;" in sheet
|
| 136 |
+
assert skipped == []
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_spaces_and_underscores_in_a_name_become_hyphens() -> None:
|
| 140 |
+
sheet, _skipped = _theme("Background Deep: #16213e\nmuted_grey: #888")
|
| 141 |
+
assert "--background-deep: #16213e;" in sheet
|
| 142 |
+
assert "--muted-grey: #888;" in sheet
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_a_hex_value_with_the_hash_left_off_is_completed() -> None:
|
| 146 |
+
"""The commonest near-miss, and unambiguous."""
|
| 147 |
+
|
| 148 |
+
sheet, skipped = _theme("primary: 1a1a2e")
|
| 149 |
+
assert "--primary: #1a1a2e;" in sheet
|
| 150 |
+
assert skipped == []
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_a_colour_that_cannot_be_read_is_skipped_and_named() -> None:
|
| 154 |
+
sheet, skipped = _theme("primary: #1a1a2e\naccent: rgb(20,20,30)")
|
| 155 |
+
assert "--primary: #1a1a2e;" in sheet
|
| 156 |
+
assert "--accent" not in sheet
|
| 157 |
+
assert skipped and "accent" in skipped[0]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_a_palette_with_nothing_usable_is_still_refused() -> None:
|
| 161 |
+
"""There is no stylesheet to make, so saying so is the honest answer."""
|
| 162 |
+
|
| 163 |
+
from distinct_skills.handlers import theme_css
|
| 164 |
+
|
| 165 |
+
with pytest.raises(SkillInputError):
|
| 166 |
+
theme_css({"name": "x", "colors": "accent: rgb(1,2,3)"}, None)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_the_refusal_says_which_lines_it_could_not_read() -> None:
|
| 170 |
+
from distinct_skills.handlers import theme_css
|
| 171 |
+
|
| 172 |
+
with pytest.raises(SkillInputError, match="rgb"):
|
| 173 |
+
theme_css({"name": "x", "colors": "accent: rgb(1,2,3)"}, None)
|