Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/changes/unreleased/1713-strip-skills-block-from-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
kind: fixed
title: skills context stays out of messages, rewind, reopened chats and titles
pr: 1713
surface: [chat]
invalidates:
- "#1627 was believed to have fixed #1504 (the `Skills suited to this message:` list inside your message). It kept the journal and store to your words, but the live copy still carried the block into reopened conversations, `/export`, rewind drafts, compaction and title input. Now those keep only your words for new messages; the model's copy still carries the block."
---
`Skills suited to this message:` is context for the model. One shared helper
reads the message's injection mark so display, rewind, compaction, the turn's
explanation and title input keep only the person's words. The model's copy keeps
the block, and a block the person pasted keeps every word.

A conversation compacted by an earlier build may already have the block saved in
its journal. `/export` and rewinding to a message from before the update can still
carry that saved block; the conversation on screen does not show it. Nothing
removes it by matching its wording: a pasted block must keep every word.
14 changes: 10 additions & 4 deletions internal/manual/chat/skills-a-turn-used.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ Those names come from the turn's skill list, not by taking apart the words in th
line. The row is a record of what that turn carried with it. It is not a warning,
a question or work waiting for you, so it has no attention mark, count or action.

The message in the transcript is your words and nothing else. What a turn
carries for the model — the skill bodies it reads with your sentence — rides the
copy the model reads, and no surface prints it: the row above is the one thing
you are shown.
The message in the transcript is your words and nothing else. Rewinding to that
message hands back only your words, too. What a turn carries for the model — the
skill bodies it reads with your sentence — rides the copy the model reads; the
row above tells you which skills it carried.

A conversation compacted by an older build may already have the skills block
saved as part of its message. `/export` and rewinding to a message from before the
update may still carry that saved block; the conversation on screen does not show
it. codeaf does not remove blocks by their wording, because a block you pasted
yourself must keep every word.

## Did it use my skill?

Expand Down
4 changes: 4 additions & 0 deletions internal/session/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -4827,6 +4827,10 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese
replyTags = append(replyTags, journal.taskReplyTags(msg)...)
}
displayText := messageContentText(msg)
// personWords removes only this message's recorded skills injection.
if role == "user" {
displayText = presentation.personWords(msg)
}
interrupted, explicitlyHuman := false, false
if mark := presentation.of(msg); role == "assistant" && mark != nil {
interrupted = mark.Interrupted
Expand Down
26 changes: 26 additions & 0 deletions internal/session/presentation.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package session

import (
"strings"
"sync"

"github.com/Agent-Field/agentfield/sdk/go/ai"
Expand All @@ -10,10 +11,19 @@ import (
// messagePresentation records who a producer meant to address. The provider's
// content remains untouched; Text, when supplied, is the exact human portion of
// a mixed message. Operational records remain available behind disclosure.
//
// SkillsBlock is provenance of another kind: the exact bytes
// [Agent.attachTurnSkillsLocked] spliced onto the copy of THIS message the
// model reads, so shapeEntries can take them off the display by record rather
// than by pattern — a block the person typed themselves has no mark and keeps
// every word. It is memory only: new journal writes keep the typed words, so
// those restored messages need no mark. An older compacted journal can still
// carry the saved block without a mark.
type messagePresentation struct {
Audience string `json:"audience"`
Text *string `json:"text,omitempty"`
Interrupted bool `json:"interrupted,omitempty"`
SkillsBlock string `json:"-"`
}

// Message identity follows its immutable content allocation, as reasoning repair
Expand Down Expand Up @@ -50,6 +60,22 @@ func (p *presentationIndex) of(message ai.Message) *messagePresentation {
return &mark
}

// personWords keeps the person's words separate from the model's skills context.
// THE MODEL'S SKILLS CONTEXT IS NOT CONVERSATION. It rides the copy in a.messages
// the provider reads, and quoting it as the person's message would put words in
// their mouth. The strip is by PROVENANCE, never by pattern: only a message
// attachTurnSkillsLocked marked, and only the exact suffix it appended, comes
// off. A skills block the person typed or pasted themselves keeps every word.
// New journal writes keep these words too; an older compacted journal has no
// injection mark, so its saved bytes remain untouched. A nil index has no marks.
func (p *presentationIndex) personWords(message ai.Message) string {
text := messageContentText(message)
if mark := p.of(message); mark != nil && mark.SkillsBlock != "" {
return strings.TrimSuffix(text, mark.SkillsBlock)
}
return text
}

func humanPresentation(text string) *messagePresentation {
return &messagePresentation{Audience: "human", Text: &text}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/session/rewind.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ func (a *Agent) rewindPointsLocked() []RewindPoint {
if !skipped {
point := RewindPoint{Index: index, Turn: turn, Entry: entry}
if turn {
point.Said = said
point.Said = a.presentation.personWords(message)
}
points = append(points, point)
}
Expand Down
14 changes: 11 additions & 3 deletions internal/session/sessionfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -2845,22 +2845,30 @@ func (s *sessionFile) appendCompaction(pass compactionPass, tokensBefore int, wi
Timestamp: stamp(),
})
for index, message := range window {
// Marks belong to the original message, before projecting a fresh
// journal copy. The live window still carries the model's context.
note := s.isNote(message)
steer := s.steerMark(message)
presentation := s.presentation.of(message)
// A KEPT LINE IS RE-JOURNALED AS WHAT IT WAS. The window is written again
// on the far side of the marker (above), and a note re-written without its
// mark would come back from the next resume as the person's words — this
// pass is the one place a message is journaled twice.
if s.isNote(message) {
if note {
s.appendNote(message, noteMarks{
tags: s.taskReplyTags(message),
deliveries: s.noteDeliveriesOf(message),
})
continue
}
if message.Role == "user" && presentation != nil && presentation.SkillsBlock != "" {
message = textMessage("user", s.presentation.personWords(message))
}
// AND SO IS A SPLICED ONE, for the same reason: a steer re-written without
// its mark would come back from the next resume as a question of its own,
// and the turn it was typed into would lose the correction that shaped it.
if mark := s.steerMark(message); mark != nil {
s.appendSteer(message, *mark)
if steer != nil {
s.appendSteer(message, *steer)
continue
}
var reasoning provider.MessageReasoning
Expand Down
14 changes: 13 additions & 1 deletion internal/session/skillturn.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const skillTurnMax = 4
// it does in a task's brief).
const skillTurnResolveLimit = store.SkillShelfLimit

// turnSkillsLead introduces the block [turnSkills] splices onto the copy the
// model reads.
const turnSkillsLead = "\n\nSkills suited to this message:\n"

// attachTurnSkillsLocked composes the block for one message the person is
// sending and splices it onto what the model reads, under a.mu, at the one
// door every person-typed message passes through (agent.go submitUser). It is
Expand All @@ -69,6 +73,14 @@ func (a *Agent) attachTurnSkillsLocked(user *userMessage) {
}
user.message = textMessage("user", messageContentText(user.message)+block)
user.skills = carried
// Provenance for the display door: shapeEntries strips the block by THIS
// mark — the exact bytes appended to this one message — and never by
// matching the text, so a block the person pasted into a message of their
// own is kept word for word.
if a.presentation == nil {
a.presentation = &presentationIndex{}
}
a.presentation.remember(user.message, &messagePresentation{SkillsBlock: block})
}

// turnSkills composes the skills one message carries and renders them as the
Expand Down Expand Up @@ -131,7 +143,7 @@ func (a *Agent) turnSkills(text string) (string, []string) {
if block == "" {
return "", nil
}
return "\n\nSkills suited to this message:\n" + block, carried
return turnSkillsLead + block, carried
}

// fillSkillRoom takes at most room names off the retrieved half. It is the one
Expand Down
Loading
Loading