Skip to content

Commit 1e42e24

Browse files
authored
Merge pull request #4012 from docker/fix/harness-delegation-empty-prompt
fix(runtime): scope harness prompt-selection to fresh vs resume session
2 parents 21cbc76 + 718abaf commit 1e42e24

2 files changed

Lines changed: 96 additions & 8 deletions

File tree

pkg/runtime/harness.go

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ func (r *LocalRuntime) runHarnessAgent(ctx context.Context, sess *session.Sessio
4848

4949
// Harnesses own their context; run lifecycle hooks but do not forward injected instructions.
5050
r.executeTurnStartHooks(ctx, sess, a, events)
51-
messages := harnessInputMessages(sess)
51+
harnessSessionID := harnessSessionIDFor(sess, a)
52+
messages := harnessInputMessages(sess, harnessSessionID)
5253
stop, msg, rewritten := r.executeBeforeLLMCallHooks(ctx, sess, a, modelID, 1, messages)
5354
if stop {
5455
slog.WarnContext(ctx, "before_llm_call hook signalled run termination",
@@ -61,8 +62,6 @@ func (r *LocalRuntime) runHarnessAgent(ctx context.Context, sess *session.Sessio
6162
messages = rewritten
6263
}
6364
messages = r.applyBeforeLLMCallTransforms(ctx, sess, a, modelID, messages)
64-
65-
harnessSessionID := harnessSessionIDFor(sess, a)
6665
prompt := strings.TrimSpace(harnessPrompt(messages))
6766
if prompt == "" {
6867
msg := "cannot run external harness without a user prompt"
@@ -421,7 +420,22 @@ func (r *LocalRuntime) rememberHarnessSessionID(ctx context.Context, sess *sessi
421420
}
422421
}
423422

424-
func harnessInputMessages(sess *session.Session) []chat.Message {
423+
// harnessInputMessages selects the messages to use as the harness prompt.
424+
// On resume (harnessSessionID != "") only the latest non-implicit user turn is
425+
// sent; the harness already holds prior context from its own session.
426+
// On a fresh session (harnessSessionID == "") the full delegated context is
427+
// included — system/task message plus all user messages — because the harness
428+
// receives no system prompt of its own and the task can only arrive via prompt.
429+
func harnessInputMessages(sess *session.Session, harnessSessionID string) []chat.Message {
430+
if harnessSessionID != "" {
431+
return latestUserTurn(sess)
432+
}
433+
return freshHarnessMessages(sess)
434+
}
435+
436+
// latestUserTurn returns the most recent non-implicit user message, used on
437+
// resume turns where the harness already holds the preceding context.
438+
func latestUserTurn(sess *session.Session) []chat.Message {
425439
for _, item := range slices.Backward(sess.MessagesSnapshot()) {
426440
if item.Message != nil && !item.Message.Implicit && item.Message.Message.Role == chat.MessageRoleUser {
427441
return []chat.Message{item.Message.Message}
@@ -430,13 +444,46 @@ func harnessInputMessages(sess *session.Session) []chat.Message {
430444
return nil
431445
}
432446

447+
// freshHarnessMessages collects the full delegated context for a new harness
448+
// session: all system and user messages (including implicit ones) in order.
449+
// Returns nil when the session contains no meaningful content — i.e. only an
450+
// implicit filler with no system/task message and no real user text — so the
451+
// empty-prompt guard in runHarnessAgent can still catch that degenerate case.
452+
func freshHarnessMessages(sess *session.Session) []chat.Message {
453+
var msgs []chat.Message
454+
hasMeaningful := false
455+
for _, item := range sess.MessagesSnapshot() {
456+
if item.Message == nil {
457+
continue
458+
}
459+
msg := item.Message.Message
460+
switch msg.Role {
461+
case chat.MessageRoleSystem:
462+
msgs = append(msgs, msg)
463+
hasMeaningful = true
464+
case chat.MessageRoleUser:
465+
msgs = append(msgs, msg)
466+
if !item.Message.Implicit {
467+
hasMeaningful = true
468+
}
469+
}
470+
}
471+
if !hasMeaningful {
472+
return nil
473+
}
474+
return msgs
475+
}
476+
477+
// harnessPrompt concatenates the content of all supplied messages (system and
478+
// user) to form the prompt string sent to the external harness binary.
433479
func harnessPrompt(messages []chat.Message) string {
434-
for _, message := range slices.Backward(messages) {
435-
if message.Role == chat.MessageRoleUser {
436-
return harnessMessageContent(message)
480+
var parts []string
481+
for _, message := range messages {
482+
if content := harnessMessageContent(message); content != "" {
483+
parts = append(parts, content)
437484
}
438485
}
439-
return ""
486+
return strings.Join(parts, "\n\n")
440487
}
441488

442489
func harnessMessageContent(msg chat.Message) string {

pkg/runtime/harness_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ func TestHarnessAgentResumesPersistedSession(t *testing.T) {
165165
assert.Equal(t, "thread-123", harnessSessionIDFor(loaded, rt.CurrentAgent()))
166166
}
167167

168+
// TestHarnessRejectsImplicitOrMissingUserPrompt checks the genuine empty-prompt
169+
// case: an implicit "Please proceed." with no task/system message is
170+
// meaningless to the harness, so the run must be rejected before launch.
171+
// Contrast with TestHarnessDelegatedTaskWithImplicitUserMessage below, which
172+
// verifies that a delegation carrying a real task (system message + implicit
173+
// user) succeeds.
168174
func TestHarnessRejectsImplicitOrMissingUserPrompt(t *testing.T) {
169175
if stdruntime.GOOS == "windows" {
170176
t.Skip("shell script shim test")
@@ -181,6 +187,41 @@ func TestHarnessRejectsImplicitOrMissingUserPrompt(t *testing.T) {
181187
assert.ErrorIs(t, err, os.ErrNotExist)
182188
}
183189

190+
// TestHarnessDelegatedTaskWithImplicitUserMessage is a regression test for
191+
// https://github.com/docker/docker-agent/issues/4011. A transfer_task
192+
// delegation builds a sub-session where the task text lives in the system
193+
// message and the only user message is the implicit "Please proceed."
194+
// filler injected by newSubSession. Before the fix this was rejected with
195+
// "cannot run external harness without a user prompt"; now the full
196+
// delegated context (system/task + implicit user) must be forwarded as the
197+
// harness prompt.
198+
func TestHarnessDelegatedTaskWithImplicitUserMessage(t *testing.T) {
199+
if stdruntime.GOOS == "windows" {
200+
t.Skip("shell script shim test")
201+
}
202+
203+
useHarnessShim(t, "codex", `{"type":"item.completed","item":{"type":"agent_message","text":"delegation done"}}
204+
`)
205+
rt := newHarnessRuntime(t, "codex")
206+
// Mirror exactly what newSubSession builds for a transfer_task delegation:
207+
// the task goes into a system message and the user message is implicit.
208+
task := "implement the widget"
209+
sess := session.New(
210+
session.WithSystemMessage(buildTaskSystemMessage(task, "a working widget", nil)),
211+
session.WithImplicitUserMessage("Please proceed."),
212+
)
213+
events := collectRuntimeEvents(t, rt, sess)
214+
215+
// Must launch without error.
216+
assert.False(t, hasEventType(t, events, &ErrorEvent{}))
217+
assert.Equal(t, "delegation done", sess.GetLastAssistantMessageContent())
218+
219+
// Harness args must carry the task text so the harness can act on it.
220+
args := harnessShimArgs(t, "codex")
221+
assert.Contains(t, args, task)
222+
assert.Contains(t, args, "<task>")
223+
}
224+
184225
func TestHarnessSubSessionIDSurvivesReconstruction(t *testing.T) {
185226
if stdruntime.GOOS == "windows" {
186227
t.Skip("shell script shim test")

0 commit comments

Comments
 (0)