Repository navigation
feat(harness): send the model found in the agent's code, and ask once for the prompt's lines (TH-8953) - #268
Conversation
… for the prompt's lines (TH-8953) The agent document always sent "model": null, and the prompt was only the model's quote whenever it didn't name the prompt's lines. The description now records the model the agent talks to the person with: provider, name, and the file and line the name is written on. The save keeps it only when that exact name is on that line as a whole name; otherwise it says what to fix. The cited path is resolved within the save's shared lookup budget and the file is read as UTF-8, at most 1 MiB, so a crafted repository can't stall the check. With no source to check against, the model is dropped. build_authored_agent copies provider and name into the document's model. A description without a model saves and builds to the same bytes and setup hash as before, so stored agent versions are unaffected. When the model quotes the prompt without naming its lines, the save asks once for the lines; a second identical save keeps the quote. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
| return raw.decode("utf-8", errors="replace").splitlines() | ||
|
|
||
|
|
||
| def _named_on(line: str, name: str) -> bool: |
There was a problem hiding this comment.
P2: the whole-name check passes for things that aren't the model's name.
This only checks the characters on either side of the match, never that the match is the value the model is set from. Run through accept_contract and build_authored_agent, all of these are saved:
MODEL = os.getenv("OPENAI_MODEL")with nameOPENAI_MODEL: the document says{"provider": "openai", "name": "OPENAI_MODEL"}llm = ChatOpenAI(model=settings.llm_model)with namesettings.llm_modelmodel = "gpt-4o" # was gpt-4o-miniwith namegpt-4o-mini(a comment)FALLBACK="gpt-4o"; MODEL="gpt-4o-mini"with namegpt-4o(another variable)
The Agents page would show these as the model. The intended cases work (gpt-4o vs gpt-4o-mini, getenv defaults, YAML, ChatOpenAI(model="gpt-4o")); only SKILL.md stops the model citing an identifier, and the test says the save is the only gate.
Suggestion: in code files require the match to sit between matching quotes; in .yaml/.toml/.json accept an unquoted value after : or =.
There was a problem hiding this comment.
Fixed in eadebfc. The name now has to be the value written on that line:
- In code: between matching quotes, and not a key (
"x":) or a lookup (after a subscript[, or the first argument ofget(/getenv(). - In config files (YAML, TOML, JSON, INI,
.env, Dockerfile, shell): it can also be written bare after:or=. - Comments: nothing inside one counts (
#,//,/* */,<!-- -->). - Setting names: a name shaped like a setting is refused too (
OPENAI_MODEL,openai_model,model). That catches yourgetenvcase, which the quote rule alone would still accept, because the name is quoted there.
Your four examples:
OPENAI_MODEL: refused.settings.llm_model: refused.- the comment: refused.
FALLBACK="gpt-4o"; MODEL="gpt-4o-mini": still accepted. Telling which of two quoted values a line assigns needs parsing, so I've listed it under known limits in the PR.
Tests:
test_a_name_that_is_not_the_model_is_never_sent(throughaccept_contractandbuild_authored_agent)test_a_setting_name_is_not_a_modeltest_a_lookup_key_is_not_the_modeltest_a_value_in_a_comment_is_not_the_modeltest_model_whole_name_is_found
| ( | ||
| # A quote may stop short of the whole prompt; lines are copied whole. Asked once, | ||
| # since a prompt built at run time has no lines to name, and its quote is kept. | ||
| "prompt-lines", |
There was a problem hiding this comment.
P2: the named lines replace a correct quote, and nothing checks them against it.
With a quote and system_prompt_from given, _copy_prompt (line 397) overwrites system_prompt_excerpt with whatever lines are named. Example: import os / DB = "x" / PROMPT = """You are pair, a helpful assistant...""", quote "You are pair, a helpful assistant.", lines 1-2: saved, and the agent's instructions become import os\nDB = "x".
The copy gap was already there, but this nudge now asks every quote-only prompt for lines, so a model under pressure guesses a range far more often.
Fix: when both are given, refuse if the whitespace-normalised copied text doesn't contain the first ~80 characters of the quote, naming the file and range.
There was a problem hiding this comment.
Fixed in eadebfc. When a quote and lines are both given, the lines must hold the quote, or the save refuses and names the file and range. I compare letters and digits only, with \n and \uXXXX escapes decoded, so prompts written as string pieces or stored in JSON still fit. The check uses the first 80 characters, and the copied text can't be shorter than the quote. If the same lines come back a second time, the quote is kept and the lines are dropped. That way a reading never sends unchecked code and never gets stuck. Your example is refused.
Tests:
test_named_lines_must_contain_the_quotetest_the_quote_windowtest_honest_prompt_forms_hold_the_quotetest_a_prompt_with_escaped_characters_holds_the_quotetest_lines_refused_twice_leave_the_quote
| _MODEL_NAME_CHARS = frozenset(string.ascii_letters + string.digits + "._:/@-") | ||
|
|
||
|
|
||
| def _resolved_in_source(root: Path, path: str) -> Path | None: |
There was a problem hiding this comment.
P2: about 115 lines of new functions in a 1,712-line file.
_resolved_in_source, _source_lines, _named_on and _model_problems (410-513) are a real seam: _checked_against_source already imports source_tool_evidence lazily (line 245). A source_model.py beside it would keep tools.py from growing, or the body could say why it stays (standard 13).
There was a problem hiding this comment.
Moved out to src/fi/alk/harness/model_citation.py in eadebfc; tools.py only calls it now. I didn't use source_model.py because that file already exists in the repo for the source facts.
| return None | ||
|
|
||
|
|
||
| def _source_lines(path: Path, limit: int) -> list[str] | None: |
There was a problem hiding this comment.
P3: this copies _copy_prompt's reader, and the copies already differ.
The docstring says "the way _copy_prompt reads them", and _resolved_in_source repeats its charged_path lookup (345-374). They already drift: this catches OSError on open, _copy_prompt doesn't (a chmod-000 prompt file makes the save raise PermissionError, while the same file cited for the model is refused cleanly). Have _copy_prompt call these two instead.
There was a problem hiding this comment.
Fixed in eadebfc. _copy_prompt now resolves and reads through the same helpers as the model check: resolved_in_source, read_source and source_lines. A chmod-000 prompt file is now refused as "not a file in the source" instead of raising PermissionError. Its limits and messages are unchanged. Test: test_an_unreadable_prompt_file_is_refused_not_raised.
| "copied whole.", | ||
| ), | ||
| ] | ||
| # All of them together, and each only once. Nudging in sequence would cost a turn per |
There was a problem hiding this comment.
P3: the prompt nudges do arrive one after another now.
This says nudging in sequence never happens, but a contract with no excerpt gets the "prompt" nudge, the next one with only a quote gets "prompt-lines", and only the third is accepted: two extra submissions. Skip "prompt-lines" when "prompt" is already in nudged, or put the ask for lines into the "prompt" message.
There was a problem hiding this comment.
Fixed in eadebfc. Once the "prompt" nudge has been sent, "prompt-lines" isn't sent as well, and the "prompt" message now asks for the lines itself. The "prompt" nudge also stays quiet when lines are named without a quote. Otherwise following the quote advice would set it off. Tests: test_one_prompt_nudge_at_a_time, test_leaving_the_quote_out_is_not_asked_about.
| return [f"model: line {model.line} is outside {shown_path}, which has {len(lines)} lines"] | ||
| if not _named_on(lines[model.line - 1], name): | ||
| return [ | ||
| f"model: the name {shown_name} is not on line {model.line} of {shown_path} as a " |
There was a problem hiding this comment.
P3: a wrong model citation is refused on every save.
The nudges beside it refuse once and then accept, and the no-source branch drops the model (line 255), but this refuses each time. The loop does end (the message says "or leave model out", and understand stops at max_turns), but an optional display field can now cost turns or fail a reading whose tools and schema were fine. Track "model" in nudged and drop it on the second mismatch.
There was a problem hiding this comment.
Fixed in eadebfc. Through submit_contract, a refused model citation is refused once, with a line saying it'll be left out if it's refused again. The second time, the model is dropped and the save goes through. Tests: test_a_wrong_citation_is_refused_once_then_dropped, and test_accept_contract_refuses_a_wrong_citation_every_time for direct calls.
| return lambda arguments: asyncio.run(tool.handler(dict(arguments))) | ||
|
|
||
|
|
||
| # P1, P8 |
There was a problem hiding this comment.
P3: # P1, P8 here (and # P3a-P3d, # P7 below) point to a plan that isn't in the repo (standard 11). Name the behaviour instead, or drop them.
There was a problem hiding this comment.
Fixed in eadebfc: the comments name the behaviour now, not the plan.
… line, and check named prompt lines against the quote (TH-8953) Answers the review of #268. - The model check moves to model_citation.py. A name counts only when it is the value written on the line: between matching quotes and not a key or a lookup (a subscript, get(), getenv()), or bare after : or = in a configuration file, and never inside a comment. A name shaped like a setting (OPENAI_MODEL, openai_model, model) is refused. - When a quote and lines are both given, the lines must hold the quote, compared by letters and digits with escapes decoded. A second mismatch in one reading keeps the quote and drops the lines. - A refused model citation is refused once per reading, then dropped, and the prompt nudges come one at a time. - _copy_prompt reads through the same bounded helpers, so an unreadable prompt file is refused instead of raising. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…hecks (TH-8953)
- The named prompt lines must hold every piece of the quote, in order; the quote
is split at ... and compared by letters and digits after escapes, HTML
entities, NFKC and case folding. The 80-letter window and the length check go.
- A refused quote is held for the reading, so lines named later without a quote
are still checked against it.
- Settings: capitals need an underscore to read as an environment variable; a
setting word among the name's parts counts when it has an underscore or no
digit; a dotted name with no digit is a setting. GPT4O, O1, mistral_large and
gpt4o-deployment are models again.
- Lookups: more lookup calls, spaces before the bracket, a generic type.
- Comments: one scan that skips quoted strings; markers without spaces, a lone
--, and block-comment middle lines count.
- Prose files: backticks are not quotes. Makefile, .mk, .flaskenv and
${VAR:-default} are configuration.
- read_source opens with O_NONBLOCK, O_NOFOLLOW and O_CLOEXEC, checks S_ISREG
on the descriptor, reads to the limit in a loop and logs a failure by path and
error kind. #225's two prompt-reader tests now watch os.open and os.read.
Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…, and close two known limits (TH-8953) Local, not pushed: review round 3 did not clear (two Highs, reports in the ticket folder); the push waits on Khushal's call. - The quote's words are matched in order within 24 letters of each other, any gap at "...", with a charged budget; the reading's latest quote is held at the top of submit_contract; lines sent alone are refused once more, then the quote is kept and the lines recorded in unconfirmed_prompt_from. - One line scanner for strings, comments and triple-quoted strings, per language; prose and prompt templates hold no model; more lookups and configuration forms; the reader logs the error kind only. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
Local, not pushed: review round 4 runs before the push (Khushal: go with A). - Unspaced scripts: only a letter is a word. - The quote is read as the contract reads it for the hold and the nudges; short quotes are held while nothing longer is; a quote under 6 letters says nothing; at the second mismatch a short quote keeps the lines, recorded unconfirmed. - Each call decides from what was asked before it was sent. - // and /* only in languages with block comments; # per language; config templates read as their inner kind; env.py is code. - SKILL item 8 records the call-direction line in notes. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…that can't fail (TH-8953)
Plants for round 3's fixes left eleven survivors. Four were caught by other
tests. Six get pins: URLs, globs, ${#x} and $# in a script without a suffix;
rt/tr string prefixes; a triple quote after floor division in Python; a quote
sent alone after lines alone were refused is not asked for lines.
_opens_a_string's 'whole' check could not fail: a prefix is one or two
letters and the scan looks back three, so it is removed.
Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…-8953) Lines are numbered at each newline, as the reading's Read and Grep number them, so a form feed, a lone CR or U+2028 no longer moves a cited line. A JSON-escaped surrogate pair reads as one character. JSON keeps // and /* */ comments (JSONC). TypeScript modules and Scala scripts have no # comments; .env files read # after a space by suffix too; Python files named like a Dockerfile or .env read # anywhere. An underscore counts in the apostrophe look-back. A template named for configuration (.env.j2) is configuration. A dead clause is removed. Pins for the 6- and 16-letter boundaries, the not-a-regular-file log line, an escape past U+10FFFF, and eight rule parts. SKILL item 6 says what happens with short quotes. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
A call's own short quote, found letter for letter in its own lines, passes and becomes the held quote, so a verbatim short quote sent after a refused paraphrase keeps the right lines. The quote is read after nulls are dropped, as the contract reads it, and a blank quote is none. The once-only nudges decide from what was asked before the call, like the refusals. The refusals say what the fallback will do (a quote of a few words keeps the lines), and a saved call that took the fallback says so. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… update (TH-8953) Plants on 4396675: a list quote with a null and the wrong lines is refused as the call's own quote; asked_before's update in the nudges did nothing, since a call either returns with the nudge or had nothing new to add. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
A short quote of 6-15 letters is looked for piece by piece at its ..., so "Be warm ... brief." holds its lines. A call's own short quote on its own lines replaces the held quote only when it is two words or more and the held quote isn't one that lines already held, so a name like PROMPT or a sentence repeated in a comment no longer overrides a verified quote. The messages say "fewer than 16 letters" and "refused again against it"; SKILL item 6 says when the lines are asked about once more. A template is configuration only when its stem is a configuration name (.env.j2, Dockerfile.j2), and system.prompt.j2 is a prompt. Pins for .conf, config.json.j2, quote precedence, padded short quotes, case and punctuation in a correction. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
| at = close + 3 | ||
| else: | ||
| stripped = line.lstrip() | ||
| if (block_comments and stripped.startswith(("* ", "*\t"))) or ( |
There was a problem hiding this comment.
P3. A commented-out model inside a multi-line /* */ block is accepted when its line doesn't start with *. Only * middle lines count as comments here, and block_open_before tracks triple quotes only, so the scan doesn't know a block comment is open.
Repro: a.js with
/*
model: "gpt-4o"
*/
and the model cited as gpt-4o, a.js, line 2: model_problems returns no problem and the model is kept.
Fix: carry an open /* across lines in block_open_before, as it already does for """, and treat the cited line as text until */.
| ) | ||
| elif asked & {"prompt-quote", "prompt-lines-alone"}: | ||
| # Either way the lines are kept on record as unconfirmed. | ||
| contract.unconfirmed_prompt_from = list(contract.system_prompt_from) |
There was a problem hiding this comment.
P3. Nothing on the platform reads unconfirmed_prompt_from yet, so what this branch saves is shown as the agent's prompt with no flag.
- Quote of 16 letters or more: correct lines sent twice with a paraphrase save the paraphrase as
system_prompt_excerpt, and the lines go only intounconfirmed_prompt_from. That outcome is your call; the platform will just show the paraphrase. - Shorter quote: the lines are kept as the prompt. Repro with the
_BOOKINGfile:system_prompt_excerpt="Be warm and brief."with lines 1-4, sent twice. The second call saves the four import lines as the prompt.
Fix: for the short-quote case, don't send lines that failed twice as the prompt (leave it empty and keep them only as unconfirmed). The platform side needs a ticket to surface unconfirmed_prompt_from.
658bc17
into
feat/TH-8804-source-subfolder
… into TH-9491 - tools.py: _save_contract keeps the single writer (write_contract) and takes #268's new reply text, which names unconfirmed prompt lines. - contract.py: #268's new fields, unconfirmed_prompt_from and model (ModelSource), are marked as kept as written: they hold source lines and the cited model name, read by the build. The test that fails on an unmarked field caught them. Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
Summary
The Agents tab shows an agent's prompt and model. Until now the agent document always sent
"model": null, and the prompt was only the reading model's short quote whenever it didn't name the lines the prompt is written on. This fixes both, for builds from a repository or an upload (TH-8953).modelentry: provider, name, and the file and line where the name is written. The save keeps it only if that exact name is on that line, as a whole name (gpt-4oisn't found insidegpt-4o-mini). Otherwise it tells the reading model what to fix, or to leave the model out. With no source to check against, the model is dropped.build_authored_agentcopies the provider and name into the document's existingmodelfield. Temperature and token limit stay null. No field changes name or shape.system_prompt_excerpt, as I decided; there's no new field.contract.jsonand the document'ssetup_hashare byte-identical to the base. A test pins the exact sha-256 and hash. This matters because the platform refuses a second, different document for a stored version.Stacked on #225 (
feat/TH-8804-source-subfolder), with its head 22bcbb8 merged in. It moves todevonce #225 merges.Known limits:
+, or one built across several lines, can't be recorded and stays null.After review (10 Nov)
Changes for the review comments:
src/fi/alk/harness/model_citation.py(source_model.pywas already taken)._copy_promptnow resolves and reads through the same bounded helpers, so an unreadable prompt file is refused instead of raising."x":) or a lookup (after a subscript[, or the first argument ofget(/getenv(). In configuration files (YAML, TOML, JSON, INI,.env, Dockerfile, shell), it can also be written bare after:or=. Nothing inside a comment counts. A name shaped like a setting is refused:OPENAI_MODEL,openai_model,model.\nand\uXXXXescapes decoded, so string pieces and JSON fit. The lines can't be shorter than the quote. A second mismatch in one reading keeps the quote and drops the lines.Fixes that no reviewer has seen:
?(a ternary) isn't read as a key.--,;and*line markers are no longer treated as comments.\u/\xescapes are decoded./*and<!--comments are recognised.These went in after the last of my two review rounds cleared (nothing above Medium), with tests and a planted bug for each.
Known limits added in that round, still open:
FALLBACK="gpt-4o"; MODEL="gpt-4o-mini"), either is accepted.gpt-4ois only found insideopenai/gpt-4owhen it's cited whole.After the second review (11 Nov)
Dileep's 8 new comments are answered in each thread. Each change went through cold review rounds of three fresh reviewers (quote, rules, flows) until a round had nothing above Medium. The last round's Mediums and Lows were then fixed with tests and planted bugs, and are listed below.
The quote check (4525093427, 4525093451):
...or…..... A quote under 6 letters says nothing.\n, as Claude Code's Read numbers them: a form feed, a lone\ror U+2028 no longer moves a cited line.The quote is held, and lines sent alone are checked (4525093437):
unconfirmed_prompt_from. A long quote is kept as the prompt. A quote under 16 letters keeps the lines instead (Khushal's call).extension.contract.The line rules (4525093459, 4525093467, 4525093475, 4525093482):
getOrThrow,getProperty,?.[and more.deploymentis no setting word.#where it is a comment (after a space in YAML, shell, Makefile, Dockerfile,.env);//and/* */in languages that have block comments and in JSON (JSONC);--in SQL, Lua and Haskell; INI;lines. Triple-quoted strings are tracked in the languages that have them, and Python string prefixes are read.config.yaml.j2,.env.j2,Dockerfile.j2) is read as that file..flaskenv, Dotprompt frontmatter and${MODEL:-gpt-4o}are read.The reader (4525093490):
read_sourceopens withO_NONBLOCK | O_NOFOLLOW | O_CLOEXEC, checksS_ISREG, reads in a loop, and logs only the error's kind.Fixed after the last round, with tests and planted bugs, not reviewed again (per the rule above):
dockerfile_writer.j2,system.prompt.j2) are text;Known limits (each listed in the ticket's ARCHITECTURE.md):
.format(...)) refuses honest lines once. A quote with filled-in placeholders doesn't match.splitlines(); they disagree with the save only when a form feed or similar sits above a cited line.{# #}), HOCON//, Go raw strings' middle lines, some C# and Swift raw strings, an unclosed"or backtick, a Rust lifetime, YAML'tis, shellfoo_'b'.|block) reads as a value.f"{prov}/gpt-4o"and YAML flow lists leave the model empty.GPT_4Oandchat-modelread as settings.Verification
uv run ruff check .: not run on the whole repo;ruff checkon the changed files passes.tests/harness/test_contract_model.py,tests/providers/test_providers_agent_authored.py,tests/test_harness.py, and the save, nudge, refusal, scenario-source and setup-hash files). Result on the pushed head: 9 failed, 2426 passed, 4 errors. The base gives the same 9 failures and 4 errors by name, so none is new.uv run pytest -q: not run locally (laptop limits); CI runs it.agent-learn release-proof: not touched.Each new check was proven by planting a bug in it and watching a test fail: 240 of 240 caught after the second review's fixes. The timing probes (worst crafted model check 0.23 s, whole save 0.32 s) covered slow coding lines, files over 1 MiB, FIFOs, links to
/dev/zero, link fan-outs and unwinding chains, on Python 3.11, 3.12.3 and 3.12.14. The plan went through three cold review rounds, and the change through one; it cleared with 1 Medium and 9 Lows. Dileep's second review went through five cold rounds of three reviewers: the first four each found a High, and after the third and the fourth Khushal decided how to go on; the fifth cleared with 2 Mediums and 11 Lows, all fixed or listed above.Checklist
agent_learning.*,agent-learn, or@future-agi/agent-learning-kit🤖 Generated with Claude Code