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
16 changes: 14 additions & 2 deletions packages/agentlayer-core/src/tools/subagent-fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,24 @@ function invokesToolCall(message: ModelMessage, toolCallId: string): boolean {
)
}

function isInjectedSkillMessage(message: ModelMessage): boolean {
if (message.role !== 'user' || typeof message.content !== 'string') return false
const content = message.content.trim()
return /^<skill(?:\s|>)/.test(content) && content.endsWith('</skill>')
}

function isUserTurnBoundary(message: ModelMessage): boolean {
return message.role === 'user' && !isInjectedSkillMessage(message)
}

function removeInvokingTurn(messages: ReadonlyArray<ModelMessage>, toolCallId: string): ModelMessage[] {
const invokingAssistantIndex = messages.findIndex((message) => invokesToolCall(message, toolCallId))
if (invokingAssistantIndex < 0) return [...messages]

let triggeringUserIndex = -1
for (let index = invokingAssistantIndex - 1; index >= 0; index--) {
if (messages[index]?.role === 'user') {
const message = messages[index]
if (message && isUserTurnBoundary(message)) {
triggeringUserIndex = index
break
}
Expand All @@ -46,7 +57,8 @@ function isEligibleForkMessage(message: ModelMessage): boolean {
function selectRecentTurns(messages: ModelMessage[], turns: number): ModelMessage[] {
let userTurns = 0
for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.role !== 'user') continue
const message = messages[index]
if (!message || !isUserTurnBoundary(message)) continue
userTurns += 1
if (userTurns === turns) return messages.slice(index)
}
Expand Down
27 changes: 27 additions & 0 deletions packages/agentlayer-core/test/subagent-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,33 @@ describe('forking subagent tool', () => {
expect(projectForkMessages(messages, 'none', 'spawn-call')).toEqual([])
})

test('does not treat an injected skill message as the invoking user turn boundary', () => {
const messages: ModelMessage[] = [
{ role: 'user', content: 'old request' },
{ role: 'assistant', content: [{ type: 'text', text: 'old answer' }] },
{ role: 'user', content: 'actual triggering request' },
{
role: 'user',
content: '<skill name="investigate" baseDir="/skills/investigate">\nInvestigate carefully.\n</skill>',
},
{
role: 'assistant',
content: [
{ type: 'tool-call', toolCallId: 'spawn-call', toolName: 'subagent', input: { prompt: 'work' } },
],
},
]

expect(projectForkMessages(messages, 'all', 'spawn-call')).toEqual([
{ role: 'user', content: 'old request' },
{ role: 'assistant', content: [{ type: 'text', text: 'old answer' }] },
])
expect(projectForkMessages(messages, 1, 'spawn-call')).toEqual([
{ role: 'user', content: 'old request' },
{ role: 'assistant', content: [{ type: 'text', text: 'old answer' }] },
])
})

test('constructs isolated fork state without caller tool, pending, child, or context state', () => {
const caller: AgentState = {
messages: [{ role: 'user', content: { nested: ['original'] } } as any],
Expand Down
Loading