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
1 change: 1 addition & 0 deletions packages/builtin-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export { AskUserQuestionTool } from './tools/AskUserQuestionTool/AskUserQuestion
export { BashTool } from './tools/BashTool/BashTool.js'
export { BriefTool } from './tools/BriefTool/BriefTool.js'
export { ConfigTool } from './tools/ConfigTool/ConfigTool.js'
export { GoalTool } from './tools/GoalTool/GoalTool.js'
export { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
export { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
export { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
Expand Down
212 changes: 212 additions & 0 deletions packages/builtin-tools/src/tools/GoalTool/GoalTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
completeGoal,
formatGoalStatus,
getActiveElapsedMs,
getGoal,
setGoal,
} from 'src/services/goal/goalState.js'
import { DESCRIPTION, generatePrompt } from './prompt.js'
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'

const inputSchema = lazySchema(() =>
z.strictObject({
action: z
.enum(['get', 'set', 'complete'])
.describe('The action to perform on the goal.'),
objective: z
.string()
.optional()
.describe('The goal objective. Required for "set" action.'),
message: z
.string()
.optional()
.describe('Completion message for "complete" action.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>

const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
action: z.string(),
goal: z
.object({
objective: z.string(),
status: z.string(),
tokensUsed: z.number(),
tokenBudget: z.number().nullable(),
elapsedSeconds: z.number(),
})
.optional(),
message: z.string().optional(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>

export type Input = z.infer<InputSchema>
export type Output = z.infer<OutputSchema>

export const GoalTool = buildTool({
name: 'goal',
searchHint: 'manage long-running task goals',
maxResultSizeChars: 10_000,
async description() {
return DESCRIPTION
},
async prompt() {
return generatePrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Goal'
},
shouldDefer: true,
isConcurrencySafe() {
return true
},
isReadOnly(input: Input) {
return input.action === 'get'
},
toAutoClassifierInput(input) {
if (input.action === 'get') return 'get goal status'
if (input.action === 'set') return `set goal: ${input.objective}`
return `complete goal: ${input.message ?? ''}`
},
async checkPermissions(input: Input) {
if (input.action === 'get') {
return { behavior: 'allow' as const, updatedInput: input }
}
return {
behavior: 'ask' as const,
message:
input.action === 'set'
? `Set goal: ${input.objective}`
: `Complete goal${input.message ? `: ${input.message}` : ''}`,
}
},
async call({ action, objective, message }: Input): Promise<{ data: Output }> {
if (action === 'get') {
const goal = getGoal()
if (!goal) {
return { data: { success: true, action, message: 'No active goal.' } }
}
const elapsedSeconds = Math.floor(getActiveElapsedMs(goal) / 1000)
return {
data: {
success: true,
action,
goal: {
objective: goal.objective,
status: goal.status,
tokensUsed: goal.tokensUsed,
tokenBudget: goal.tokenBudget,
elapsedSeconds,
},
},
}
}

if (action === 'set') {
if (!objective) {
return {
data: {
success: false,
action,
error: 'objective is required for set action.',
},
}
}
setGoal(objective)
return {
data: {
success: true,
action,
message: `Goal set: ${objective}`,
goal: {
objective,
status: 'active',
tokensUsed: 0,
tokenBudget: null,
elapsedSeconds: 0,
},
},
}
}

if (action === 'complete') {
if (!completeGoal()) {
return {
data: {
success: false,
action,
error: 'No active goal to complete.',
},
}
}
return {
data: {
success: true,
action,
message: message
? `Goal completed: ${message}`
: 'Goal marked as complete.',
},
}
}

return {
data: { success: false, action, error: `Unknown action: ${action}` },
}
},
renderToolUseMessage(input: Partial<Input>) {
if (input.action === 'get') return 'Getting goal status'
if (input.action === 'set') return `Setting goal: ${input.objective ?? ''}`
if (input.action === 'complete') return 'Completing goal'
return 'Managing goal'
},
renderToolResultMessage(content: Output) {
if (!content.success) return `Error: ${content.error}`
if (content.action === 'get' && content.goal) {
const g = content.goal
return `Goal: ${g.objective} [${g.status}]`
}
return content.message ?? 'Done.'
},
mapToolResultToToolResultBlockParam(
content: Output,
toolUseID: string,
): ToolResultBlockParam {
if (!content.success) {
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Error: ${content.error}`,
is_error: true,
}
}

if (content.action === 'get' && content.goal) {
const g = content.goal
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Goal: ${g.objective}\nStatus: ${g.status}\nTokens: ${g.tokensUsed}${g.tokenBudget !== null ? ` / ${g.tokenBudget}` : ''}\nElapsed: ${g.elapsedSeconds}s`,
}
}

return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: content.message ?? 'Done.',
}
},
} satisfies ToolDef<InputSchema, Output>)
18 changes: 18 additions & 0 deletions packages/builtin-tools/src/tools/GoalTool/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const DESCRIPTION = 'Manage the active goal for long-running tasks.'

export function generatePrompt(): string {
return `Manage the active goal for long-running tasks.

Use this tool to get, set, or complete a goal. A goal is an objective that the system tracks across the session, injecting continuation prompts to keep working toward it.

## Actions
- **get** — Get the current goal status
- **set** — Set or update the goal objective
- **complete** — Mark the goal as complete when the objective is achieved

## Examples
- Get current goal: { "action": "get" }
- Set a goal: { "action": "set", "objective": "Improve test coverage to 80%" }
- Complete a goal: { "action": "complete", "message": "All tests now pass with 82% coverage." }
`
}
2 changes: 2 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ import thinkbackPlay from './commands/thinkback-play/index.js'
import permissions from './commands/permissions/index.js'
import plan from './commands/plan/index.js'
import fast from './commands/fast/index.js'
import goal from './commands/goal/index.js'
import passes from './commands/passes/index.js'
import privacySettings from './commands/privacy-settings/index.js'
import hooks from './commands/hooks/index.js'
Expand Down Expand Up @@ -316,6 +317,7 @@ const COMMANDS = memoize((): Command[] => [
exit,
fast,
files,
goal,
heapDump,
help,
ide,
Expand Down
66 changes: 66 additions & 0 deletions src/commands/goal/goal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { LocalCommandCall } from '../../types/command.js'
import {
clearGoal,
completeGoal,
formatGoalStatus,
getGoal,
pauseGoal,
resumeGoal,
setGoal,
} from '../../services/goal/goalState.js'

export const call: LocalCommandCall = async args => {
const trimmed = args.trim()

// No arguments — show current goal status
if (!trimmed) {
return { type: 'text', value: formatGoalStatus() }
}

const lower = trimmed.toLowerCase()

// Control subcommands
if (lower === 'clear') {
const goal = getGoal()
if (!goal) {
return { type: 'text', value: 'No active goal to clear.' }
}
clearGoal()
return { type: 'text', value: 'Goal cleared.' }
}

if (lower === 'pause') {
if (pauseGoal()) {
return { type: 'text', value: 'Goal paused.' }
}
return { type: 'text', value: 'No active goal to pause.' }
}

if (lower === 'resume') {
if (resumeGoal()) {
return { type: 'text', value: 'Goal resumed.' }
}
return { type: 'text', value: 'No paused goal to resume.' }
}

if (lower === 'complete') {
if (completeGoal()) {
return { type: 'text', value: 'Goal marked as complete.' }
}
return { type: 'text', value: 'No active goal to complete.' }
}

// Set a new goal
const existing = getGoal()
if (existing && existing.status === 'active') {
// Replace existing active goal
setGoal(trimmed)
return {
type: 'text',
value: `Goal replaced.\n\n${formatGoalStatus()}`,
}
}

setGoal(trimmed)
return { type: 'text', value: `Goal set.\n\n${formatGoalStatus()}` }
}
12 changes: 12 additions & 0 deletions src/commands/goal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'

const goal = {
type: 'local',
name: 'goal',
description: 'Set or view the goal for a long-running task',
supportsNonInteractive: true,
argumentHint: '<objective> | clear | pause | resume',
load: () => import('./goal.js'),
} satisfies Command

export default goal
6 changes: 6 additions & 0 deletions src/constants/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
resolveSystemPromptSections,
} from './systemPromptSections.js'
import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool/prompt.js'
import { getGoalContinuationPrompt } from '../services/goal/goalState.js'
import { TICK_TAG } from './xml.js'
import { logForDebugging } from '../utils/debug.js'
import { loadMemoryPrompt } from '../memdir/memdir.js'
Expand Down Expand Up @@ -505,6 +506,11 @@ ${CYBER_RISK_INSTRUCTION}`,
...(feature('KAIROS') || feature('KAIROS_BRIEF')
? [systemPromptSection('brief', () => getBriefSection())]
: []),
DANGEROUS_uncachedSystemPromptSection(
'goal_continuation',
() => getGoalContinuationPrompt(),
'Goal state changes between turns',
),
]

const resolvedDynamicSections =
Expand Down
8 changes: 8 additions & 0 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from '@anthropic-ai/sdk/resources/index.mjs'
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
import { FallbackTriggeredError } from './services/api/withRetry.js'
import { updateGoalTokens } from './services/goal/goalState.js'
import {
calculateTokenWarningState,
estimateMaxTurnGrowth,
Expand Down Expand Up @@ -1265,6 +1266,13 @@ async function* queryLoop(
if (warningInfo) {
yield createCacheWarningMessage(warningInfo)
}

// Update goal token usage
const totalTokens =
usage.input_tokens +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0)
updateGoalTokens(totalTokens)
}
}

Expand Down
Loading