Both prompts were clean. The merge is not.
A re-org folds the billing desk into technical support, and two agent prompts that have each passed for a year become one file by Friday. Nine checks for that week, including the three conflicts nothing will report.
TL;DR Concatenating two agent prompts that each check at 0 error, 0 warning, 0 info is 6 blocking errors and F (62/100); three more conflicts are reported nowhere at all (a second $REQUIRE variable that silently wins on declaration order, two personas that never bind a name, one policy written with two verbs); and the merge that works is routing rather than reconciliation, because the same two rule sets on two arms of $SWITCH ON @{intent} is 0 error where transcluding both unconditionally is 2.
The re-org is announced on a Monday. Billing and technical support are becoming one team called Customer Care, the two queues are collapsing into one, and somewhere in the third slide there is a line about a single agent experience. By Wednesday somebody has asked you how long it takes to merge two prompts, and the honest answer is that the merge is not the work. The merge takes ten seconds. Finding out what the merge did takes the week.
The two prompts below are the live ones. Each has been in production for about a year, each has survived a launch review, and each checks completely clean. That is the interesting starting condition, because it means every finding from here belongs to the merge and not to either desk.
§1Before you merge anything
<$CONFIG> $IMPORT tool get_bill $REQUIRE variable channel: one of voice, chat </$CONFIG> # Role You are Rowan, a billing agent for Wraysbury Energy. # Constraints - You MUST keep every reply to at most 3 sentences. - You MUST NOT discuss a network outage. - You MUST refund at most 40 pounds without a manager code. - You MUST call @[get_bill] before quoting a balance. $IF @{channel} is equal to voice: You MUST read the account number back to the customer. $ELSE: You MUST send the account number as a link.
<$CONFIG> $IMPORT tool get_line_status $REQUIRE variable channel: one of voice, chat, sms </$CONFIG> # Role You are Casper, a technical support agent for Wraysbury Energy. # Constraints - You MUST keep every reply to at most 6 sentences. - You MUST explain the cause of a network outage. - You MUST call @[get_line_status] before booking an engineer. - You MUST call the customer back within 2 hours. $IF @{channel} is one of chat, sms: You MUST send the fault reference as text. $ELSE: You MUST say the fault reference out loud.
Check 1: baseline both files alone, and write the numbers down. This takes one command and it is the only way the rest of the week has a control group. Whatever the merged file reports, you want to be able to say with certainty that neither input contributed it.
$ npx typeglish check prompts/billing.tg prompts/technical.tg ✓ 2 files — 0 error, 0 warning, 0 info # exit 0 $ npx typeglish score prompts/billing.tg prompts/technical.tg prompts/billing.tg — B (85/100) proven errors: none tiers: base+z3 prompts/technical.tg — B (86/100) proven errors: none tiers: base+z3
Check 2: check the directory, not the file. This is the check that changes the shape of the week, because it lets you see the merge before you commit to it. Copy the two files into a scratch directory, add a root that pulls them in with $IMPORT file and transcludes both, then point check at the directory: the program pass composes everything the imports join and reports cross-file findings at their true file and line, blocking exactly as local ones do.
$ npx typeglish check care-flat care-flat/billing.tg:7:1 error logic/contradiction Conflicts with care-flat/technical.tg line 5. Logical conflict — "explain a network outage" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...). care-flat/technical.tg:7:1 error logic/contradiction Conflicts with care-flat/billing.tg line 5. Logical conflict — "explain a network outage" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...). ✗ 3 files — 2 error, 0 warning, 0 info program: 1 root — care-flat/care.tg (3 files) # exit 1
program: 1 root under the tick is the scope statement: this is what the compiler considers one prompt. Note that both files' checks pass individually, which is the same reason a freeze on a file is not a freeze on a prompt.§2What the concatenation will tell you
Somebody is going to do it anyway, so it is worth knowing what falls out. Two files, one cat, one check.
$ cat prompts/billing.tg prompts/technical.tg > care.tg $ npx typeglish check care.tg care.tg:6:1 error structure/duplicate-section Conflicts with line 20. Duplicate section "role" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "role") is ambiguous. care.tg:9:1 error structure/duplicate-section Conflicts with line 22. Duplicate section "constraints" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "constraints") is ambiguous. care.tg:10:1 error logic/quantifier-strength Conflicts with line 23. One rule, two counts — "keep every reply to at most 3 sentences." already sits inside "keep every reply to at most 6 sentences." (per reply); the looser bound is dead weight. Keep the tighter line. care.tg:23:1 error structure/duplicate-section Conflicts with line 5. Duplicate section "role" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "role") is ambiguous. care.tg:26:1 error structure/duplicate-section Conflicts with line 7. Duplicate section "constraints" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "constraints") is ambiguous. care.tg:27:1 error logic/quantifier-strength Conflicts with line 8. One rule, two counts — "keep every reply to at most 3 sentences." already sits inside "keep every reply to at most 6 sentences." (per reply); the looser bound is dead weight. Keep the tighter line. ✗ 1 file — 6 error, 0 warning, 0 info # exit 1 $ npx typeglish build care.tg typeglish build: care.tg refused — nothing written $ npx typeglish score care.tg care.tg — F (62/100) proven errors — grade capped at F tiers: base+z3 planes runtime 83 (what the model reads) · hygiene 0 (source only)
Check 3: rename the sections before you paste them. Four of the six errors are the same one: two # Role headings and two # Constraints headings in one scope. It is a structural error rather than a style note because a section name is an address, and a duplicated address makes every reference to it ambiguous. The same family catches the version teams reach for next, which is renaming one of the pair by half.
# Technical and Technical rules, side by side nearsec.tg:4:1 warn structure/near-duplicate-section Near-duplicate section names "technical" and "technical_rules" — read as two DISTINCT sections (references bind by exact tag). If one is a typo, consolidate; otherwise give each a clearly distinct name. (conflicts with line 5) # both desks named their first test case wrong_bill dup.tg:12:1 error structure/bad-test duplicate $TEST id "wrong_bill". dup.tg:12:1 warn clarity/duplicate Duplicate rule — "$test wrong_bill" is required in 2 places (also line 9). State it once and reference it. $ npx typeglish test dup.tg --dry ✗ dup.tg does not compile — structure/bad-test: duplicate $TEST id "wrong_bill". ✗ 1 prompt — 1 failed
test --dry reports 1 failed without running a case. Merge the section names and the test ids in the same pass, because they collide for the same reason.Check 4: reconcile every bound into one line in one place. The other two errors are the brevity caps, and the message is worth reading closely: One rule, two counts, and the looser bound is named as the dead weight. Both desks were right for their own queue, so there is no mechanical answer here. Somebody has to decide the number, and the value of the diagnostic is that it will not let you ship both while you make up your mind. Do the same sweep by hand for every bound the checker cannot pair up across a merge: refund caps, callback windows, hold times, escalation thresholds.
§3The three it will not tell you
Six errors from a concatenation feels like a thorough report. It is not, and the gap is not a defect in the checker so much as three places where two prompts overlap in ways no prover can see. These three are the manual part of the week.
Check 5: reconcile the $REQUIRE variable domains by hand. Billing declares channel: one of voice, chat. Technical declares channel: one of voice, chat, sms. The merged file declares both, and there is no diagnostic for that at all. The last declaration wins, so the order in which you concatenated the two files decides which channels exist.
<$CONFIG> $REQUIRE variable channel: one of voice, chat $REQUIRE variable channel: one of voice, chat, sms </$CONFIG> # Role You are a care agent for Wraysbury Energy. # Constraints - You MUST greet the customer by name. $SWITCH ON @{channel} - voice:: Say the fault reference out loud. - chat:: Send the fault reference as text.
sms is a member of the domain, the switch does not cover it, and the file is 1 blocking error.<$CONFIG> $REQUIRE variable channel: one of voice, chat, sms $REQUIRE variable channel: one of voice, chat </$CONFIG> # Role You are a care agent for Wraysbury Energy. # Constraints - You MUST greet the customer by name. $SWITCH ON @{channel} - voice:: Say the fault reference out loud. - chat:: Send the fault reference as text.
sms is gone from the domain, coverage is proven over two members, and the file is clean.$ npx typeglish check wide-last.tg narrow-last.tg wide-last.tg:11:1 error structure/non-exhaustive-switch @{channel} can be sms, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 2 files — 1 error, 0 warning, 0 info $ npx typeglish check narrow-last.tg --strict ✓ 1 file — 0 error, 0 warning, 0 info # exit 0
--strict too. The technical desk took SMS traffic; the merged prompt now has no SMS handling and nothing anywhere says so. Write the union out by hand, once, in the root, and delete both originals.Check 6: do not expect the persona check to fire. Two agents with two names is exactly the kind of thing you would hope a merge report leads with. It does, but only if the names were written as possessions in the first place.
# Role You are Rowan, a billing agent for Wraysbury Energy. # Technical role You are Casper, a technical support agent for Wraysbury Energy. # Constraints - You MUST keep every reply to at most 4 sentences.
# Your name is Rowan. / Your name is Casper. $ npx typeglish check persona-named.tg persona-named.tg:2:1 error logic/contradiction Conflicts with line 5. Contradiction — single-valued "you.name" is assigned 2 different values ("rowan", "casper"). Keep one, or hold several with a list ("… is rowan and casper"). persona-named.tg:6:1 error logic/contradiction Conflicts with line 2. Contradiction — single-valued "you.name" is assigned 2 different values ("rowan", "casper"). Keep one, or hold several with a list ("… is rowan and casper"). ✗ 1 file — 2 error, 0 warning, 0 info # exit 1
Your name is Rowan. and Your name is Casper., and the merge report leads with the persona after all. Worth doing in both files the week before the merge rather than after it, for the reasons in your agent has two names.Check 7: read both files for one policy written with two verbs. This is the dangerous one, because it is a genuine behaviour conflict about a customer-facing statement, and the concatenation reports nothing about it. Billing says MUST NOT discuss a network outage. Technical says MUST explain the cause of a network outage. Two policies, one topic, opposite directions.
# Role You are a care agent for Wraysbury Energy. # Constraints - You MUST NOT discuss a network outage. - You MUST explain the cause of a network outage.
# Role You are a care agent for Wraysbury Energy. # Constraints - You MUST NOT explain a network outage. - You MUST explain a network outage.
discuss and explain are separate keys in the verb ontology, so the first pair never met. Aligning the verbs across the two files before the merge is what turns this class of conflict into a build failure, and it is the same mechanic behind two rules with no words in common.§4Route, do not reconcile
The instinct when a merge produces conflicts is to negotiate them: get both desks in a room, pick one outage policy, delete the other rule. Sometimes that is right. Usually it is not, because the two rules were never in conflict operationally. They applied to different conversations, and the thing that scoped them was the deployment, not the file. Put the scope in the file and both rules can stay.
Check 8: give each desk an arm, not a paragraph. Three files: the two desks' rules in their own files with distinct section names, and a root that routes on a typed input.
<$CONFIG> $IMPORT file "billing.tg" as billing $IMPORT file "technical.tg" as technical $IMPORT tool get_bill, get_line_status $REQUIRE variable channel: one of voice, chat, sms $REQUIRE variable intent: one of billing, technical </$CONFIG> # Role You are Rowan, a care agent for Wraysbury Energy. # Care rules @@ brevity: one bound in one place - 4 sentences is the reconciled figure both desks signed - You MUST keep every reply to at most 4 sentences. @@ channel: the reference is read aloud on voice and sent as text everywhere else $IF @{channel} is equal to voice: You MUST say the reference number out loud. $ELSE: You MUST send the reference number as text. $SWITCH ON @{intent} - billing:: @<billing.billing_rules> - technical:: @<technical.technical_rules> $TEST outage_on_billing - input:: My bill is wrong and my broadband is down. - expect:: - at most 4 sentences
<$CONFIG> $IMPORT tool get_bill </$CONFIG> # Billing rules @@ outage_scope: outage wording is the technical desk's to own - You MUST NOT explain a network outage. @@ cap: the desk refund limit finance signed off in March - You MUST refund at most 40 pounds without a manager code. @@ balance: never quote a balance from memory - You MUST call @[get_bill] before quoting a balance.
MUST explain a network outage under # Technical rules. Both leaves check clean alone, which is the point of keeping them separate: each desk still owns a file it can read.# both sections transcluded unconditionally at the root $ npx typeglish check care-flat ✗ 3 files — 2 error, 0 warning, 0 info program: 1 root — care-flat/care.tg (3 files) # the same two sections, one per arm of $SWITCH ON @{intent} $ npx typeglish check care ✓ 3 files — 0 error, 0 warning, 0 info program: 1 root — care/care.tg (3 files) # exit 0
$SWITCH buys, and it is why the answer to a merge conflict is usually a variable rather than a deletion.Check 9: hoist the machine plane, build the root, and cut over on the hash. Three practical notes for the day itself, each of which cost us a wrong turn.
Tool declarations do not travel with a transcluded section. A leaf that declares $IMPORT tool get_bill and then uses @[get_bill] is fine alone and is structure/undefined-tool-inline in the program, because the pointer lands in the root and the root never declared it. Declare every tool the program uses at the root as well.
$ npx typeglish build care/care.tg --vars '{"channel":"voice","intent":"billing"}' ✓ built .typeglish/dist/care/care.txt ← care/care.tg (b285651b3825, full) # Care rules - You MUST keep every reply to at most 4 sentences. You MUST say the reference number out loud. - You MUST NOT explain a network outage. - You MUST refund at most 40 pounds without a manager code. - You MUST call get_bill before quoting a balance. $ npx typeglish build care/care.tg --vars '{"channel":"chat","intent":"technical"}' ✓ built .typeglish/dist/care/care.txt ← care/care.tg (ba0c665ed71b, full) $ npx typeglish build care/care.tg ✓ built .typeglish/dist/care/care.txt ← care/care.tg (a096b9b929e8, full) # Care rules - You MUST keep every reply to at most 4 sentences. If channel is voice: You MUST say the reference number out loud. Otherwise: You MUST send the reference number as text. If intent is billing: - You MUST NOT explain a network outage. - You MUST refund at most 40 pounds without a manager code. - You MUST call get_bill before quoting a balance. If intent is technical: - You MUST explain a network outage. - You MUST call get_line_status before booking an engineer. - You MUST call the customer back within 2 hours.
intent unbound the routing dissolves back into prose and the model receives both desks, including both sides of the outage policy, at a096b9b929e8 and exit 0. The routed merge is only as good as the value the host sends, so fail the deploy on a template build the way a missing input deserves.Build the root, not the directory. build on a directory compiles everything that passes and names each refusal, which is the honest behaviour and not what you want here: the leaves are not standalone prompts, so they refuse on the root's input bag and you get ✗ built 1/3 — 2 refused with the one artifact you wanted sitting in the middle of it.
And keep the two old artifacts. The cutover argument you will be asked for is not that the new prompt is good, it is that nothing was lost, and the only form of that argument anybody can check in a meeting is three hashes and a diff of what reached the model.
$ npx typeglish test care/care.tg --dry ✓ care/care.tg coverage: 0/3 rules exercised · outage_on_billing — "My bill is wrong and my broadband is down." (not run) ✓ at most 4 sentences ✓ 1 prompt — 0 failed
0 failed is honest rather than reassuring, because --dry validates the suite offline without sending a case to a model, and 0/3 rules exercised is the number to argue about: the assert mirrors the brevity bound and still attributes to no rule. A merge doubles the rule count and inherits neither desk's test suite intact. Write the cases for the conversations that only exist after the merge, starting with the one above, where a customer arrives with a billing question and a fault in the same sentence.None of the nine is expensive. Seven are one command, and the two that are not, reconciling the bounds and reconciling the variable domains, are decisions that were always going to need a human. What they add up to is a merge you can describe: two clean baselines, six findings from the concatenation, three found by reading, one routing variable, and three hashes.
§5Common questions
- How do I merge two AI agent system prompts into one?
- Not by concatenating them. Keep each desk's rules in its own file, add a root file that pulls them in with
$IMPORT fileand routes between them on a typed input, and check the directory rather than the file:typeglish check care/runs the program pass and reports cross-file conflicts at their true file and line before a merged file exists. Two live prompts that each check at 0 error, 0 warning, 0 info concatenate to 6 blocking errors and F (62/100), and the routed version of the same content is 0 error, 0 warning, 0 info withprogram: 1 root, care/care.tg (3 files)printed under the tick. - What conflicts appear when two agent prompts are combined?
- Four kinds, in descending order of how loudly they announce themselves. Structural collisions come first: duplicate section names, near-duplicate section names, and a duplicate
$TESTid, which is a blockingstructure/bad-test. Then reconcilable bounds: two reply-length caps in one file islogic/quantifier-strength, one rule with two counts, and the looser bound is named as dead weight. Then the conflicts nothing reports: a second$REQUIRE variabledeclaration of the same name silently wins on order, and two personas written as You are Rowan and You are Casper never bind a name slot at all. Last, the policy conflict written with two verbs, which no prover can fold together. - Why does my merged prompt still pass after I combined two conflicting rules?
- Most often because the two teams used different verbs for one policy.
MUST NOT discuss a network outagebesideMUST explain a network outageis 0 error, 0 warning, 0 info, because discuss and explain are separate keys in the verb ontology. Rewrite either line so both use the same head and the pair is 2 blockinglogic/contradictionerrors reading explain a network outage is both required and forbidden. The pre-merge job is to align the verbs across the two files, not to trust a green check on the joined one. - Should one agent handle two queues, or should they stay separate?
- Either works if the merged prompt routes rather than reconciles. Put each desk's rules in its own file and transclude them from two arms of
$SWITCH ON @{intent}: the compiler resolves one arm, so the losing desk's rules never reach the model and two rules that provably conflict can both stay written down. The controlled pair is the evidence. Transcluding both sections unconditionally is 2 blockinglogic/contradictionerrors; the same three files with the two transclusions on two switch arms is 0 error. The condition is that the host actually bindsintent, because an unbound build ships both desks as conditional prose and still exits 0.
The thing that makes a prompt merge feel harder than it is, is that both inputs are correct. Nobody wrote a bug. The billing desk was right that its agents should not speculate about outages, because they had no line data and a wrong guess about an outage generates a second contact. The technical desk was right that explaining the cause of an outage is most of what a technical call is for. Both rules were true statements about a queue, and the queue was the part nobody wrote down, because for a year the queue was the deployment. That is the actual merge task: recovering the scope that was implicit in having two prompts, and writing it into the one prompt as a variable. Everything else on this list is bookkeeping. It is also why the routed version is not a compromise, and why the three artifact hashes are worth more than the six-error report: the report tells you the two prompts disagreed, and the hashes tell you which conversation now gets which answer. Related reading if you are about to do this: the paragraph that lives in three prompts for the composition mechanics, and half your prompt arrives at runtime for what the host is contributing while you are looking at the file.