Skip to content

feat(response): inject json schema into the last message instead of system prompt - #3070

Open
Rubikoid wants to merge 2 commits into
ag2ai:mainfrom
Rubikoid:rubikoid/LastMessagePromptedSchema
Open

feat(response): inject json schema into the last message instead of system prompt#3070
Rubikoid wants to merge 2 commits into
ag2ai:mainfrom
Rubikoid:rubikoid/LastMessagePromptedSchema

Conversation

@Rubikoid

@Rubikoid Rubikoid commented Jul 9, 2026

Copy link
Copy Markdown

Why are these changes needed?

I ran into the following scenario: I had a warmed-up session with a model that was supposed to respond with a structured response on .ask.

My provider didn't support "full, via API" schema passing, and PromptedSchema inserted the schema into the system prompt - as a result, the model couldn't make sense of its own prior history and produced JSON poorly (only from the second or third attempt).

After switching to LastMessagePromptedSchema, the model started producing JSON on the first or second try.

I'm not sure this is the most elegant or correct implementation — if it's not, let's refine it together.

Related issue number

Did't find corresponding.

Checks

AI assistance

  • I understand the changes in this PR and can explain them in my own words.
  • I have verified that the PR description accurately reflects the actual diff.
  • If AI assistance was used, I reviewed, tested, and validated the generated code/text before submitting.

Rubikoid added 2 commits July 10, 2026 01:53
…ser message

PromptedSchema previously injected its JSON-schema instruction only into
the system prompt. Some models attend more strongly to the end of the
conversation, and some endpoints lack a system-prompt slot entirely.

Add an `inject_to: Literal["system", "last_message"]` parameter to
PromptedSchema (default "system", preserving existing behaviour). When
"last_message" is chosen, the instruction is stored in the new
`last_message_prompt` attribute on ResponseProto instead of
`system_prompt`, and the agent loop appends it as a TextInput to the
final ModelRequest before each LLM call.

LastMessagePromptedSchema is a convenience subclass equivalent to
PromptedSchema(inner, inject_to="last_message"), re-exported from the
top-level `ag2` package.

Tests cover: schema construction from types/dataclasses/pydantic models,
instruction landing in the last user message (and absent from the system
prompt), ask-level override, callable schemas, retry re-injection, and
bedrock/zai mapper no-op cases.
@Rubikoid
Rubikoid requested a review from marklysze as a code owner July 9, 2026 22:55
@CLAassistant

CLAassistant commented Jul 9, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added area:docs Documentation (website/) area:core Agent runtime core: ag2/*.py, ag2/events, ag2/response, ag2/streams area:providers LLM provider clients and mappers (ag2/config) labels Jul 9, 2026

@genisis0x genisis0x 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.

The motivation makes sense — for providers without native structured output, and especially without a usable system prompt, putting the schema instruction near the generation boundary is a real improvement, and the inject_to / LastMessagePromptedSchema opt-in keeps the default behavior intact. _inject_last_message_prompt is also nicely done: it rebuilds the list and the ModelRequest copy instead of mutating, and no-ops safely. Multi-provider tests (bedrock/zai/agent/prompted) are a good touch.

Two things I'd look at, both around the "last message" semantics:

  1. It targets the last ModelRequest, not literally the last event. In a plain .ask with no tools that's the same thing, so it works. But once there are tool round-trips, the most recent ModelRequest (the user turn) can sit several events back — behind ToolResultsEvents and prior ModelResponses — so the schema instruction is no longer adjacent to where the model generates. Since the whole point is "the model pays more attention to the end of the conversation," it might be worth appending a fresh trailing turn at generation time rather than augmenting the last user request, so the instruction is always last. At minimum, a note in the docstring that "last message" means "last user request" would avoid surprise.

  2. Silent no-op when there's no ModelRequest. _inject_last_message_prompt returns messages unchanged if it finds no ModelRequest, which means the schema guidance is dropped entirely for that call (rather than, say, falling back to the system prompt). That's an easy corner to hit with a history that starts from a non-request event. Consider falling back to system injection (or emitting a warning) in that branch, and adding a test for the no-ModelRequest case to lock whichever behavior you choose.

Minor: appending the instruction as a TextInput onto the existing user ModelRequest.parts merges it into the user's own message. That's probably fine, but a one-line comment on why it's merged into the last request vs. sent as a distinct message would help the next reader.

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

Labels

area:core Agent runtime core: ag2/*.py, ag2/events, ag2/response, ag2/streams area:docs Documentation (website/) area:providers LLM provider clients and mappers (ag2/config)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants