ADR-0031: System prompt injection strategy
Accepted — proxy-owned Inject with system_first, system_append, and user_prepend; pure packages/injection; no directive content in logs.
ADR-0031: System prompt injection strategy
- Status: Accepted
- Date: 2026-07-24
- Authors: IBEX Harness team
- Milestone: 2.3.3 System prompt injection
Context
Milestone 2.3.2 resolves an agent's active directive and stashes content + injection_mode on the request context. Without writing that content into the LLM messages array, directives have no effect on model behaviour.
Injection strategy is agent-level configuration (directives.injection_mode from ADR-0030). Different modes change how strongly the directive competes with client-supplied system messages. OpenAI models accept multiple system messages; some other providers historically preferred a single system or instruction channel — user_prepend exists for that compatibility path.
ADR-0025 and ADR-0026 already require that provider clients do not implement directive injection. The proxy owns the messages array before provider.Complete.
Decision
- Proxy owns injection: Apply injection in the chat forward path after
llm.ToProviderRequest, usingResolvedDirectiveFromContext. Provider clients receive already-injected[]provider.Message. - Shared pure package:
packages/injection.Inject(messages, directive, mode)returns a new slice and never mutates the input. Safe for concurrent use. Latency budget <0.5ms (in-memory only). - Three modes (aligned with Postgres enum):
system_first— directive is the first message withrole=system; originals follow (strongest enforcement; default).system_append— directive is inserted after the leading contiguous system block; if none, insert as first system message.user_prepend— firstusermessage content becomes[DIRECTIVE]: <content>\n\n<original>. If no user message exists, leave messages unchanged (no-op copy).
- Empty content: If directive content is empty (or resolve fail-open left no context), return messages unchanged (still allocate a defensive copy when calling Inject with empty content, or skip Inject entirely in the proxy).
- Unknown / empty mode: Treat as
system_first(matches DB default anddirective.DefaultInjectionMode). - Privacy: Never log or trace directive content. Metrics/logs may record mode or presence only.
- Phase 3 reuse: Memory / context assembly should call the same
Inject(or a thin wrapper) rather than re-implementing message splicing inside provider clients.
Consequences
- Chat path order remains: auth → agent verify → rate limit → directive resolve → parse → provider routing → inject →
Complete. - OpenAI multi-system guidance: multiple system messages are valid for GPT-4o-class models;
system_first/system_appendrely on that. - Clients that already send system prompts keep them; IBEX never silently drops client system messages.
- Benchmark synthetic
stagePromptInjectremains a follow-on for Goal 2.6 (real wiring), not a blocker for 2.3.3.
Alternatives considered
- Inject inside OpenAI client — couples one provider to agent config; rejected by ADR-0025/0026.
- Middleware that mutates
llm.ChatCompletionRequestbefore conversion — possible, but the public Inject API is defined onprovider.Message; converting then injecting keeps one splice implementation. - Single forced system message only — simpler; loses append / prepend strategies required by the schema enum.
Was this page helpful?
Last updated on