Skip to content

Commit 397be0e

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@669db686422313ac30ae2d42d877038364465c9a
1 parent 4f648de commit 397be0e

18 files changed

Lines changed: 980 additions & 56 deletions

‎bun.lock‎

Lines changed: 52 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎common/src/__tests__/freebuff-public-data-use-copy.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('public Freebuff data-use copy', () => {
3737
version: '2026-09-02',
3838
effectiveDate: 'September 2, 2026',
3939
lastUpdated: '09/02/2026',
40+
privacyPolicyLastUpdated: '09/24/2026',
4041
})
4142
expect(FREEBUFF_AI_TRAINING_NOTICE).toBe('May use data for AI training')
4243
expect(FREEBUFF_PUBLIC_DATA_USE_COPY.storageAnswer).not.toContain(

‎common/src/constants/freebuff-data-use.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const FREEBUFF_POLICY_METADATA = {
44
version: '2026-09-02',
55
effectiveDate: POLICY_EFFECTIVE_DATE,
66
lastUpdated: '09/02/2026',
7+
privacyPolicyLastUpdated: '09/24/2026',
78
} as const
89

910
export const FREEBUFF_PRIVACY_POLICY_URL = 'https://freebuff.com/privacy-policy'
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
export const PROJECT_PROFILE_TOOL_NAME = 'report_project_profile'
2+
3+
export const PROJECT_APP_KINDS = [
4+
'Web app',
5+
'Mobile app',
6+
'Desktop app',
7+
'Backend / API',
8+
'CLI tool',
9+
'Library / package',
10+
'Game',
11+
'Data / ML',
12+
'Other',
13+
] as const
14+
15+
export type ProjectAppKind = (typeof PROJECT_APP_KINDS)[number]
16+
17+
export const PROJECT_PROFILE_STATUSES = [
18+
'unchanged',
19+
'updated',
20+
'not_enough_context',
21+
] as const
22+
23+
export const PROJECT_PROFILE_REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000
24+
25+
export const PROJECT_PROFILE_MAX_TECHNOLOGIES = 60
26+
27+
export const PROJECT_PROFILE_MAX_VALUE_LENGTH = 80
28+
29+
export type ProjectProfile = {
30+
appKinds: string[]
31+
technologies: string[]
32+
}
33+
34+
export type ProjectProfileReport =
35+
| { changed: false }
36+
| { changed: true; appKinds: ProjectAppKind[]; technologies: string[] }
37+
38+
function formatList(values: string[]): string {
39+
return values.length > 0 ? values.join(', ') : '(none)'
40+
}
41+
42+
export function buildProjectProfileRequest(
43+
current: ProjectProfile | null,
44+
): string {
45+
const currentProfile = current
46+
? [
47+
'Current profile of this project:',
48+
` app_kinds: ${formatList(current.appKinds)}`,
49+
` technologies: ${formatList(current.technologies)}`,
50+
].join('\n')
51+
: 'This project has no profile yet.'
52+
return [
53+
'<system_instructions>',
54+
`This is an internal request, not from the user. Call ${PROJECT_PROFILE_TOOL_NAME} once and do nothing else.`,
55+
'',
56+
currentProfile,
57+
'',
58+
'Answer only from what you have actually seen of this project in this conversation: files you read, commands you ran, output you saw. Do not guess.',
59+
'If you have not seen enough of the project to describe it, set status to "not_enough_context".',
60+
'If the current profile is still correct, set status to "unchanged".',
61+
'Otherwise set status to "updated" and send the complete new profile. It replaces the old one, so keep values from the current profile unless you know they are wrong.',
62+
`app_kinds lists every kind of app in the project, one or more of: ${PROJECT_APP_KINDS.join(', ')}.`,
63+
'technologies lists the programming languages, frameworks, libraries, databases, platforms and tools the project uses, including Git and any CI service.',
64+
'Use the names from the Stack Overflow Developer Survey (for example "TypeScript", "Next.js", "PostgreSQL", "Docker"). For anything not in the survey, use its official name.',
65+
'</system_instructions>',
66+
].join('\n')
67+
}

‎common/src/tools/constants.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const TOOLS_WHICH_WONT_FORCE_NEXT_STEP = [
2020
'suggest_followups',
2121
'suggest_prompts',
2222
'task_completed',
23+
'report_project_profile',
2324
]
2425

2526
// List of all available tools
@@ -45,6 +46,7 @@ export const toolNames = [
4546
'read_subtree',
4647
'read_url',
4748
'render_ui',
49+
'report_project_profile',
4850
'run_file_change_hooks',
4951
'run_terminal_command',
5052
'set_messages',

‎common/src/tools/list.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { readFilesParams } from './params/tool/read-files'
2323
import { readSubtreeParams } from './params/tool/read-subtree'
2424
import { readUrlParams } from './params/tool/read-url'
2525
import { renderUIParams } from './params/tool/render-ui'
26+
import { reportProjectProfileParams } from './params/tool/report-project-profile'
2627
import { runFileChangeHooksParams } from './params/tool/run-file-change-hooks'
2728
import { runTerminalCommandParams } from './params/tool/run-terminal-command'
2829
import { setMessagesParams } from './params/tool/set-messages'
@@ -65,6 +66,7 @@ export const toolParams = {
6566
read_subtree: readSubtreeParams,
6667
read_url: readUrlParams,
6768
render_ui: renderUIParams,
69+
report_project_profile: reportProjectProfileParams,
6870
run_file_change_hooks: runFileChangeHooksParams,
6971
run_terminal_command: runTerminalCommandParams,
7072
set_messages: setMessagesParams,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import z from 'zod/v4'
2+
3+
import { textToolResultSchema } from '../utils'
4+
import {
5+
PROJECT_APP_KINDS,
6+
PROJECT_PROFILE_STATUSES,
7+
PROJECT_PROFILE_TOOL_NAME,
8+
} from '../../../constants/project-profile'
9+
10+
import type { $ToolParams } from '../../constants'
11+
12+
const toolName = PROJECT_PROFILE_TOOL_NAME
13+
const endsAgentStep = true
14+
const inputSchema = z
15+
.object({
16+
status: z
17+
.enum(PROJECT_PROFILE_STATUSES)
18+
.describe(
19+
'unchanged: the current profile is correct. updated: send the new profile. not_enough_context: you have not seen enough of the project yet.',
20+
),
21+
app_kinds: z
22+
.array(z.enum(PROJECT_APP_KINDS))
23+
.optional()
24+
.describe('Required when status is updated.'),
25+
technologies: z
26+
.array(z.string())
27+
.optional()
28+
.describe(
29+
'Required when status is updated. The complete list; it replaces the old one.',
30+
),
31+
})
32+
.describe('Report what kind of project this is and what it is built with.')
33+
const description = `
34+
Internal tool. Call it only when a message explicitly asks you to report the project profile. Never call it otherwise, and never mention it to the user.
35+
`.trim()
36+
37+
export const reportProjectProfileParams = {
38+
toolName,
39+
endsAgentStep,
40+
description,
41+
inputSchema,
42+
outputSchema: textToolResultSchema(),
43+
} satisfies $ToolParams

‎common/src/types/contracts/agent-runtime.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import type {
1515
DatabaseAgentCache,
1616
FetchAgentFromDatabaseFn,
1717
FinishAgentRunFn,
18+
GetProjectProfileFn,
1819
GetUserInfoFromApiKeyFn,
20+
ReportProjectProfileFn,
1921
StartAgentRunFn,
2022
} from './database'
2123
import type { ClientEnv, CiEnv } from './env'
@@ -39,6 +41,8 @@ export type AgentRuntimeDeps = {
3941
startAgentRun: StartAgentRunFn
4042
finishAgentRun: FinishAgentRunFn
4143
addAgentStep: AddAgentStepFn
44+
getProjectProfile?: GetProjectProfileFn
45+
reportProjectProfile?: ReportProjectProfileFn
4246

4347
// Billing
4448
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn

‎common/src/types/contracts/database.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import type {
2+
ProjectProfile,
3+
ProjectProfileReport,
4+
} from '@codebuff/common/constants/project-profile'
15
import type { AgentTemplate } from '@codebuff/common/types/agent-template'
26
import type { Logger } from '@codebuff/common/types/contracts/logger'
37

@@ -113,4 +117,15 @@ export type AddAgentStepFn = (params: {
113117
logger: Logger
114118
}) => Promise<string | null>
115119

120+
export type GetProjectProfileFn = (params: {
121+
logger: Logger
122+
signal?: AbortSignal
123+
}) => Promise<{ due: boolean; profile: ProjectProfile | null } | null>
124+
125+
export type ReportProjectProfileFn = (params: {
126+
report: ProjectProfileReport
127+
logger: Logger
128+
signal?: AbortSignal
129+
}) => Promise<boolean>
130+
116131
export type DatabaseAgentCache = Map<string, AgentTemplate | null>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import * as analytics from '@codebuff/common/analytics'
2+
import { TEST_USER_ID } from '@codebuff/common/old-constants'
3+
import { createTestAgentRuntimeParams } from '@codebuff/common/testing/fixtures/agent-runtime'
4+
import { clearMockedModules } from '@codebuff/common/testing/mock-modules'
5+
import {
6+
createMockDbOperations,
7+
setupDbSpies,
8+
} from '@codebuff/common/testing/mocks/database'
9+
import { getInitialSessionState } from '@codebuff/common/types/session-state'
10+
import { promptSuccess } from '@codebuff/common/util/error'
11+
import {
12+
afterAll,
13+
afterEach,
14+
beforeEach,
15+
describe,
16+
expect,
17+
it,
18+
mock,
19+
spyOn,
20+
} from 'bun:test'
21+
22+
import { loopAgentSteps } from '../run-agent-step'
23+
import { clearAgentGeneratorCache } from '../run-programmatic-step'
24+
import { createToolCallChunk, mockFileContext } from './test-utils'
25+
26+
import type { AgentTemplate } from '../templates/types'
27+
import type { ProjectProfileReport } from '@codebuff/common/constants/project-profile'
28+
import type { DbSpies } from '@codebuff/common/testing/mocks/database'
29+
30+
describe('project profile report in loopAgentSteps', () => {
31+
let dbSpies: DbSpies
32+
let runtimeImpl: any
33+
let requests: any[]
34+
let reports: ProjectProfileReport[]
35+
let events: string[]
36+
37+
const template: AgentTemplate = {
38+
id: 'free-root',
39+
displayName: 'Free Root',
40+
spawnerPrompt: 'Testing',
41+
model: 'deepseek/deepseek-v4-flash',
42+
inputSchema: {},
43+
outputMode: 'last_message',
44+
includeMessageHistory: true,
45+
inheritParentSystemPrompt: false,
46+
mcpServers: {},
47+
toolNames: ['read_files', 'end_turn'],
48+
spawnableAgents: [],
49+
systemPrompt: 'Test system prompt',
50+
instructionsPrompt: '',
51+
stepPrompt: '',
52+
handleSteps: undefined,
53+
} satisfies AgentTemplate as AgentTemplate
54+
55+
const runLoop = async (params: { costMode: string; due: boolean }) => {
56+
const {
57+
agentTemplate: _,
58+
localAgentTemplates: __,
59+
...baseRuntimeParams
60+
} = createTestAgentRuntimeParams()
61+
let finished!: () => void
62+
const finishedPromise = new Promise<void>((resolve) => {
63+
finished = resolve
64+
})
65+
runtimeImpl = {
66+
...baseRuntimeParams,
67+
finishAgentRun: mock(async () => {
68+
events.push('finish')
69+
finished()
70+
}),
71+
getProjectProfile: mock(async () => ({
72+
due: params.due,
73+
profile: { appKinds: ['Web app'], technologies: ['TypeScript'] },
74+
})),
75+
reportProjectProfile: mock(async ({ report }: any) => {
76+
events.push('report')
77+
reports.push(report)
78+
return true
79+
}),
80+
}
81+
runtimeImpl.promptAiSdkStream = mock(async function* (request: any) {
82+
requests.push(request)
83+
if (
84+
JSON.stringify(request.messages.at(-1)).includes(
85+
'report_project_profile once',
86+
)
87+
) {
88+
yield {
89+
...createToolCallChunk('report_project_profile', {}),
90+
input: { status: 'unchanged' },
91+
} as ReturnType<typeof createToolCallChunk>
92+
return promptSuccess('report-message-id')
93+
}
94+
yield { type: 'text' as const, text: 'done' }
95+
yield createToolCallChunk('end_turn', {})
96+
return promptSuccess('mock-message-id')
97+
})
98+
99+
const sessionState = getInitialSessionState(mockFileContext)
100+
const result = await loopAgentSteps({
101+
...runtimeImpl,
102+
agentType: template.id,
103+
localAgentTemplates: { [template.id]: template },
104+
repoId: undefined,
105+
repoUrl: undefined,
106+
userInputId: 'test-user-input',
107+
agentState: {
108+
...sessionState.mainAgentState,
109+
agentId: 'root-agent-id',
110+
messageHistory: [],
111+
output: undefined,
112+
stepsRemaining: 5,
113+
},
114+
prompt: 'build the thing',
115+
spawnParams: undefined,
116+
fingerprintId: 'test-fingerprint',
117+
fileContext: mockFileContext,
118+
userId: TEST_USER_ID,
119+
clientSessionId: 'test-session',
120+
ancestorRunIds: [],
121+
costMode: params.costMode,
122+
onResponseChunk: () => {},
123+
signal: new AbortController().signal,
124+
} as any)
125+
await finishedPromise
126+
return result
127+
}
128+
129+
beforeEach(() => {
130+
requests = []
131+
reports = []
132+
events = []
133+
dbSpies = setupDbSpies(createMockDbOperations())
134+
spyOn(analytics, 'trackEvent').mockImplementation(() => {})
135+
})
136+
137+
afterEach(() => {
138+
if (runtimeImpl) clearAgentGeneratorCache(runtimeImpl)
139+
dbSpies.restore()
140+
mock.restore()
141+
})
142+
143+
afterAll(() => {
144+
clearMockedModules()
145+
})
146+
147+
it('offers the tool on every step and reports with the turn prefix before finishing the run', async () => {
148+
const result = await runLoop({ costMode: 'free', due: true })
149+
150+
expect(requests).toHaveLength(2)
151+
const [turn, report] = requests
152+
expect(Object.keys(turn.tools)).toContain('report_project_profile')
153+
expect(Object.keys(report.tools)).toEqual(Object.keys(turn.tools))
154+
expect(report.messages.slice(0, turn.messages.length)).toEqual(
155+
turn.messages,
156+
)
157+
expect(report.toolChoice).toBe('required')
158+
expect(report.extraCodebuffMetadata.llm_step_number).toBe('2')
159+
expect(reports).toEqual([{ changed: false }])
160+
expect(events).toEqual(['report', 'finish'])
161+
expect(JSON.stringify(result.agentState.messageHistory)).not.toContain(
162+
'report_project_profile once',
163+
)
164+
})
165+
166+
it('makes no report request when the profile is fresh', async () => {
167+
await runLoop({ costMode: 'free', due: false })
168+
expect(requests).toHaveLength(1)
169+
expect(reports).toEqual([])
170+
expect(events).toEqual(['finish'])
171+
})
172+
173+
it('leaves paid runs untouched', async () => {
174+
await runLoop({ costMode: 'normal', due: true })
175+
expect(requests).toHaveLength(1)
176+
expect(Object.keys(requests[0].tools)).not.toContain(
177+
'report_project_profile',
178+
)
179+
expect(runtimeImpl.getProjectProfile).not.toHaveBeenCalled()
180+
})
181+
})

0 commit comments

Comments
 (0)