Skip to content

system-prompt: the prompt goes after the agent's last system message - #863

Open
wahaha1024 wants to merge 2 commits into
magpie-community:mainfrom
wahaha1024:system-prompt/last-message
Open

wahaha1024 wants to merge 2 commits into
magpie-community:mainfrom
wahaha1024:system-prompt/last-message

Conversation

@wahaha1024

Copy link
Copy Markdown

What happens now

An agent does not always use the field an API reserves for a system prompt:

  • Codex-style agents on the Responses API send their prompt as developer messages in input and leave instructions empty. The middleware wrote the prompt to instructions, so it reached the model before the agent's own developer messages, which then overrode it.
  • Chat clients may send several system messages. The middleware wrote to messages[0], so every later system message came after the prompt and overrode it.

Either way the prompt was in the request and had no effect. Observed with omp on /v1/responses: a captured request has no instructions key at all and a 12,971-character developer message at input[0]; the injected prompt sat in instructions ahead of it and the model ignored it.

What this changes

  • responses: when instructions is empty, the prompt goes after the last developer or system message in input (string content joined, array content pushed as {type: "input_text"}). instructions is still written when the input carries no such message.
  • chat: the prompt goes to the last system or developer message of the run the agent opens with (the ones before the first message of another role), not to messages[0].
  • prepend keeps its meaning in both: the prompt goes before that message.
  • anthropic and gemini already wrote last and are unchanged.

Tests

cases.json gains six cases: a prompt after the last developer message in input, after the last of several system messages, prepend before it, the override: false pass-through, the fallback to instructions, and the Chat equivalents. Existing cases are unchanged.

bun test packages/system-prompt
20 pass, 0 fail, 40 expect() calls

Verified against a live magpie gateway with omp as the client: after the change, asking the model what its developer message says about the thinking language returns the injected rule, where it previously described the agent's own prompt.

An agent does not always use the field an API reserves for a system
prompt. Codex-style agents on the Responses API send their prompt as
`developer` messages in `input` and leave `instructions` empty; Chat
clients may send several `system` messages. The middleware wrote the
prompt to `instructions` when it was empty, and to `messages[0]` for
Chat, so the agent's own prompt came after it and overrode it: the
prompt was in the request and had no effect.

The prompt now goes to the last `developer` or `system` message of the
run an agent opens with, in Responses' `input` and in Chat's
`messages`, which is the one the model reads as the final word.
`instructions` is still used when the input carries no such message, and
`prepend` still puts the prompt before that message. `anthropic` and
`gemini` already wrote last and are unchanged.

Cases in cases.json cover a prompt after input's last developer
message, after the last of several system messages, prepend, the
override-off pass-through, and the fallback to `instructions`.
@yetone

yetone commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Thanks. The problem is real: with omp on /v1/responses the prompt lands in instructions, ahead of omp's own developer prompt in input. The Chat change for several leading system messages is right too. A few things need changing before this can merge.

1. No version bump. packages/system-prompt/package.json is still 0.1.0, and npm already has 0.1.0 (npm view @magpie-community/middleware-system-prompt version --prefer-online → 0.1.0). publish.yml only publishes versions npm doesn't have yet, so this change would never reach anyone. Please bump it to 0.1.1.

2. Responses looks for the last developer/system item anywhere in input. Chat only looks at the leading run. inputSystem walks back from the end of input, so the prompt goes into whichever developer message comes last, even one deep in the conversation or after the user's last turn. Real requests have such messages:

  • omp itself: with forceReasoningOff on models that need the juice instruction, pi-ai pushes a trailing {role: "developer", content: [{type: "input_text", text: "# Juice: 0 !important"}]} after the last user message (openai-responses.ts, the trailingScaffoldingItems block). On this head the prompt is appended into that scaffolding item:
    input: [{developer "OMP PROMPT"}, {user "hi"}, {developer ["# Juice: 0 !important", "MINE"]}]
    
  • Codex-style histories carry developer updates in the middle of the conversation (permissions or sandbox changes, model switches). With instructions empty, the prompt goes into the latest of these, after reasoning/function_call/function_call_output items. On the next update it moves again. Each move changes an earlier item and breaks the prompt cache from there on. The README's default append exists to keep that cache.

Please use the same rule as Chat: the last developer/system message of the run input opens with (stop at the first item that isn't one). The README's "the last ... message before the conversation" then holds for both APIs.

3. prepend doesn't keep its meaning. The README says prepend puts the prompt "before" the agent's own prompt (New API's way). Before this PR, Chat put it at the start of messages[0], and Responses put it in instructions, which comes before every input item. Now it goes before the last system/developer message, in the middle of the agent's prompt:

chat prepend, [system A, system B, user]  ->  [system "A", system "MINE\nB", user]
responses prepend, [developer A, developer B, user] -> [developer "A", developer "MINE\nB", user]

For prepend, please target the first message of the leading run (or, in Responses, keep writing instructions). The case "Chat: prepend goes before the last of several system messages" should then expect the prompt before "You code.".

4. Two smaller points:

  • override: false changes behaviour. With empty instructions and a developer message in input, the prompt used to go to instructions. Now nothing is added (your case "input's own developer message stays without override" pins this). That fits "a request with its own prompt keeps it", but a user with override: false on omp loses the prompt they have now. Please say so in the README.
  • In Responses, a developer item whose content is neither a string nor an array (null, say) makes the request pass unchanged and drops the prompt. Falling back to instructions would keep it. Not a blocker; please say which you intend.

Please also add cases for a mid-conversation developer message (after function_call/function_call_output) and a trailing one after the last user message, both left alone, plus prepend with two leading messages.

On idempotence: magpie's gateway runs onRequest once per agent request, before any fallback (serveAgent in internal/gateway/middleware.go), and each try starts from that body. Running it twice on one body would still append twice, as on main, so that isn't a concern.

What I ran at 6a5b7db (on macOS, Bun 1.4.2, temp HOME):

  • bun test packages/system-prompt: 20 pass, 0 fail.
  • Broke it by hand: put main's system-prompt.middleware.js back under your cases.json. 7 of the 9 new cases go red. The other two pin behaviour that main already has: the Chat late system message, and instructions when there is no developer message.
  • bun scripts/check.mjs: ok.
  • Repo-root HOME=$(mktemp -d) bun test: 6889 pass, 0 fail.
  • A probe script with realistic shapes. The outputs quoted above come from it: omp juice, Codex mid-conversation developer messages with instructions empty and with it set, prepend with two leading messages, null content, string input.

CI's pr run on this head is waiting for approval (first-time contributor). It will run once the next push is approved.

…epend before it

Review follow-ups on the first cut.

Only the run a conversation opens with counts, in both APIs. `inputSystem`
walked back from the end of `input`, so a developer message deep in the
conversation (a Codex permission or sandbox update) or a trailing one
(omp's `# Juice: 0 !important` scaffolding item) took the prompt. That
put it behind the agent's own prompt, and moved it again on the next
update, breaking the prompt cache from there on. Responses now uses the
same rule as Chat: the last item of the leading developer/system run,
stopping at the first item of another role.

`prepend` goes before the whole leading run again: at the start of the
first system/developer message on Chat, and into `instructions` on
Responses, which comes before every input item. It had gone in before
the last message of the run, i.e. in the middle of the agent's prompt.

`override: false` no longer writes `instructions` when `input` carries a
leading developer prompt. The agent's own developer messages would come
after it and override it, which is what this change is about; README
says so, and says that on omp, whose prompt is a leading developer
message, `override: false` adds nothing.

A Responses developer item whose content is neither a string nor an
array (`null`, say) now falls back to `instructions` instead of dropping
the prompt.

Version 0.1.1: npm has 0.1.0, and publish.yml only publishes versions
npm does not have yet.

Cases: mid-conversation and trailing developer items left alone, prepend
before two leading messages, prepend into `instructions`, null content
falling back. `bun test packages/system-prompt`: 23 pass, 0 fail.
`bun scripts/check.mjs`: ok.
@wahaha1024

Copy link
Copy Markdown
Author

All four points are in, pushed as 989f3f7.

1. Version. packages/system-prompt/package.json is 0.1.1 now.

2. Responses uses the same rule as Chat. leadingRun stops at the first item that is not developer/system, and the prompt goes to the last item of that run. Mid-conversation developer updates (Codex permissions, sandbox, model switches) and trailing scaffolding (# Juice: 0 !important) are left alone. Two cases pin it:

  • [developer "You code.", user, function_call, function_call_output, developer "You verify."] → prompt in the first item only.
  • [developer "You code.", user, developer "# Juice: 0 !important"] → prompt in the first item only.

3. prepend before the whole run. Chat prepends to messages[0], the first message of the leading run. Responses writes instructions (empty or not) rather than touching an input item, since instructions comes before every input item. The case you flagged now expects "Be brief.\nYou code.", and there is a new one for Responses prepend writing instructions.

4. The smaller points.

  • override: false no longer writes instructions when input carries a leading developer prompt; README says so explicitly and says that on omp, whose prompt is a leading developer message, override: false adds nothing and override: true is what keeps the prompt.
  • A developer item whose content is neither a string nor an array falls back to instructions. I picked the fallback: dropping the prompt silently seemed worse than placing it where the agent's own developer messages can override it, and the agent's content is unusable as a system prompt anyway. README's Responses row still says instructions, or ... for that reason.

Cases added: mid-conversation developer, trailing developer, prepend before two leading messages, prepend into instructions, null content falling back. 23 cases total.

What I ran here (Linux, Bun 1.4.4):

  • bun test packages/system-prompt: 23 pass, 0 fail, 46 expect().
  • bun scripts/check.mjs: ok.
  • Repo-root HOME=$(mktemp -d) bun test: 6893 pass, 18 skip, 1 fail. The failure is packages/window-warmup/tests/fake-gateway.ts:112 (连接中断) under the full parallel run; bun test packages/window-warmup alone is 259 pass, 0 fail, and nothing in this PR touches it.

Also verified against a live magpie gateway with omp as the client: with [developer "You are omp.", user, developer "# Juice: 0 !important"], the model still reports the injected thinking-language rule from the first developer message, and the trailing item is untouched.

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at 989f3f7. Points 1 (0.1.1; npm has 0.1.0), 2 (leadingRun for Responses too), the Chat half of 3, and the null-content fallback are in. One thing is still wrong.

Responses prepend ignores override: false. system-prompt.middleware.js:106-109 writes instructions for prepend before the !over check at :110. So with override: false and position: "prepend", a request whose own prompt is a leading developer message still gets yours:

responses, {override:false, position:"prepend"}, input [developer "OMP", user "hi"]
  -> instructions: "MINE"           (append with override:false: unchanged)
chat,      {override:false, position:"prepend"}, [system "A", user "hi"]
  -> unchanged

That contradicts the README this PR adds ("With override: false, a request that has one of those is left alone even when instructions is empty"), the Options text ("keeps it unchanged, unless override is true"), and Chat. Please do the !over && last >= 0 return before the prepend branch, and add a case for Responses prepend + override: false with a leading developer message (unchanged).

Smaller: the README's Responses row ("instructions, or the last developer or system message in input") doesn't say when each applies. A request with instructions set and a leading developer run in input (Codex) still gets the prompt appended to instructions, which is ahead of those developer messages. That's main's behaviour and not a blocker, but the row should say "when instructions is empty".

What I ran at 989f3f7 (a separate Mac, Bun 1.4.2, temp HOME):

  • bun test packages/system-prompt: 23 pass, 0 fail. bun scripts/check.mjs: ok.
  • Broke it by hand, one rule at a time:
    • leadingRun turned back into "last developer/system anywhere": 3 red (Chat late system message, Responses mid-conversation, Responses trailing Juice).
    • Chat prepend to messages[last]: 1 red.
    • The Responses prepend branch removed: 1 red.
  • A probe of the shapes above, plus string input, typed {type:"message"} items (handled), a null input item (falls back to instructions), and Codex instructions + developer.
  • Not run: the repo-wide bun test. CI shows no checks on this head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants