Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
211 changes: 211 additions & 0 deletions packages/builtin-tools/src/tools/GoalTool/GoalTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
completeGoal,
formatGoalStatus,
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((Date.now() - goal.startTime) / 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