Skip to content

Commit 62f89b9

Browse files
eastriverleeclaude
andcommitted
chore: take real people out of the tests, and out of the prompt
Test fixtures carried colleagues' names and their work email addresses, and one of them had reached the shipped system instruction: the example approval question named a real person. A repository that is about to be public should not introduce anyone by name who did not ask to be there. Fictional stand-ins replace them, in the same script so the tests still exercise what they were written for — Korean name resolution stays Korean — and every assertion moved with its fixture rather than being loosened. The example company keeps its shape and loses its identity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8901bee commit 62f89b9

14 files changed

Lines changed: 104 additions & 74 deletions

README.md

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,57 +6,87 @@ bluecollar does not own tools, identity, or storage. It is handed a tool set and
66
host and runs the turn. That separation is the point — the same loop runs behind a chat connector on
77
a server, or in front of you in a terminal.
88

9+
It is built for work nobody is watching. A request arrives from someone else, the person who sent it
10+
goes back to their day, and the answer has to be right without anyone checking. That assumption is
11+
why the loop carries things an interactive coding agent has no use for: an outcome contract agreed
12+
before the work starts, a completion gate that will not accept the model's own word that it is done,
13+
approval as a state a task can sit in for days and resume from, a tier ladder that picks the model
14+
from the difficulty rather than from a flag, and failure text written for the person who asked
15+
rather than for a log.
16+
17+
The trade is real and worth stating plainly: it is heavier than an interactive loop, and for sitting
18+
beside a developer and fixing code as they watch, a coding agent is the better tool.
19+
920
## The shape
1021

1122
```
1223
host ──── agentcontract.Harness ──── bluecollar
1324
│ │
1425
│ owns: tools, identity, task store, │ owns: the turn loop, routing,
15-
│ approvals, POSIX isolation │ skills, completion judgment
26+
│ approvals, process isolation │ skills, completion judgment
1627
│ │
1728
└──────── executes every tool call ──────┘
1829
```
1930

2031
The host and the harness compile against one shared contract package,
21-
[`agentcontract`](./agentcontract).
22-
The host names only that interface, never this package. A different harness — an AI SDK adapter, an
23-
external agent — drops into the same socket.
24-
25-
The nine verbs the host may call:
26-
27-
| Verb | Purpose |
28-
|---|---|
29-
| `RunTurn` | Run one turn of an existing task |
30-
| `RouteTurn` | Decide what an inbound message means before running anything |
31-
| `RunAgentRequest` | Route and run in one call |
32-
| `CompleteLaunchFailure` | Turn a launch failure into an explanation for the person |
33-
| `GenerateReply` | One-shot reply, no task |
34-
| `GenerateReplyWithContext` | One-shot reply with visible context and memory |
35-
| `ClassifyAddressing` | Decide whether a message in a channel is for us |
36-
| `ClassifyActiveTaskFollowUp` | Decide whether a message continues a running task |
37-
| `RefreshSkillIndex` | Re-read the skill bundle |
32+
[`agentcontract`](./agentcontract). A different harness — an AI SDK adapter, an external agent —
33+
drops into the same socket.
34+
35+
The port is one method:
36+
37+
```go
38+
type Harness interface {
39+
RunTurn(context.Context, AgentTurnRequest) (AgentTurnResult, error)
40+
}
41+
```
42+
43+
It used to be nine. Routing, addressing, follow-up classification and one-shot replies were verbs on
44+
the port until it became clear they are host policy, not harness behaviour: a host that answers
45+
its own messenger decides what an inbound message *means* before anything runs a turn. Those
46+
still live here — [`intake.Classifier`](./intake) routes and classifies, `AgentKernel` carries
47+
`RunAgentRequest` and `CompleteLaunchFailure` — but a host is free to bring its own, and a harness
48+
that implements only `RunTurn` is complete.
3849

3950
Tool execution never happens here. The harness decides *what* to call; the host decides *who* it runs
4051
as. A harness that runs its own tools defeats the host's isolation boundary and is not a valid
4152
implementation of this contract.
4253

54+
The harness has no identity of its own. The host supplies `AgentIdentity`, the workspace layout, the
55+
instruction bundle and the company context; with none given, the agent is "the assistant" and knows
56+
nothing about where it runs.
57+
4358
## Provider-agnostic
4459

4560
Models reach bluecollar through a provider port, not a vendor SDK. Anything satisfying it works, and
4661
the provider can change **between steps of a running turn** — the tier ladder relies on that, escalating
4762
a task from a cheap model to a strong one without restarting it.
4863

49-
The reference provider is an [AI SDK](https://ai-sdk.dev) sidecar, which is what makes "any model"
50-
literal rather than aspirational.
64+
There is no provider implementation in this module — the port is the contract, and the host brings
65+
the provider. The reference one, in [blueclaw](https://github.com/Dawn-kim-official/blueclaw), is an
66+
[AI SDK](https://ai-sdk.dev) sidecar, which is what makes "any model" literal rather than
67+
aspirational.
5168

5269
## What is not here yet
5370

5471
Honest list, kept current:
5572

56-
- The terminal CLI. Planned on [termcn](https://github.com/shadcn-labs/termcn).
73+
- A terminal front end of its own. Planned on [termcn](https://github.com/shadcn-labs/termcn); today
74+
the only way to drive the loop is to embed it in a host.
5775
- Native multi-step tool calling. The loop currently forces one structured action per step, which
5876
costs a turn per tool call and blocks parallel calls. Migration is planned and staged.
5977

78+
## Building and testing
79+
80+
The module depends on one library and nothing outside its own directory.
81+
82+
```
83+
go build ./...
84+
go test ./...
85+
```
86+
87+
Every check that runs in CI is in [`.github/workflows/check.yml`](./.github/workflows/check.yml):
88+
`gofmt`, `go vet`, `go build`, `go test`. No network, no credentials, no database.
89+
6090
## License
6191

6292
MIT. See [LICENSE](./LICENSE).

approval_gate_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ func TestCurrentThreadSendSkipsRuntimeApproval(t *testing.T) {
239239
if toolCallRequiresRuntimeApproval(toolSet, currentChannelCall) {
240240
t.Fatal("expected a current-channel send to run without approval, like a reply")
241241
}
242-
directMessageCall := turnActionDocument{ToolName: "message_send", ToolInput: json.RawMessage(`{"targetType":"directMessage","personHint":"우경","message":"안내"}`)}
242+
directMessageCall := turnActionDocument{ToolName: "message_send", ToolInput: json.RawMessage(`{"targetType":"directMessage","personHint":"테스트","message":"안내"}`)}
243243
if !toolCallRequiresRuntimeApproval(toolSet, directMessageCall) {
244244
t.Fatal("expected an external send to keep requiring approval")
245245
}

approval_gate_wording_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,28 +67,28 @@ func TestHeldCallConfirmationWordingAsksModelForDeclarativeDraft(t *testing.T) {
6767

6868
func TestHeldCallConfirmationWordingUsesActionFactsAsModelInput(t *testing.T) {
6969
languageModel := &sequenceLanguageModel{contents: []string{
70-
`{"question":"우경에게 다음 내용을 보낼까요?\n\n오늘 오후 3시에 확인하자"}`,
70+
`{"question":"테스트에게 다음 내용을 보낼까요?\n\n오늘 오후 3시에 확인하자"}`,
7171
}}
7272
agentTurnRunner := &AgentTurnRunner{languageModel: languageModel}
7373
request := AgentTurnRequest{
74-
Prompt: "우경에게 DM 보내줘",
74+
Prompt: "테스트에게 DM 보내줘",
7575
ResponseLanguage: ResponseLanguageKorean,
7676
}
7777
actionDocument := turnActionDocument{
7878
ToolName: "message_send",
79-
ToolInput: json.RawMessage(`{"targetType":"directMessage","personHint":"우경","message":"오늘 오후 3시에 확인하자"}`),
79+
ToolInput: json.RawMessage(`{"targetType":"directMessage","personHint":"테스트","message":"오늘 오후 3시에 확인하자"}`),
8080
}
8181

8282
confirmation, errorValue := agentTurnRunner.heldCallConfirmationWording(context.Background(), request, actionDocument)
8383

8484
if errorValue != nil {
8585
t.Fatalf("expected no error: %v", errorValue)
8686
}
87-
if confirmation != "우경에게 다음 내용을 보낼까요?\n\n오늘 오후 3시에 확인하자" {
87+
if confirmation != "테스트에게 다음 내용을 보낼까요?\n\n오늘 오후 3시에 확인하자" {
8888
t.Fatalf("expected model-generated question with message content, got %q", confirmation)
8989
}
9090
requestText := structuredRequestText(languageModel.requests[0])
91-
for _, expected := range []string{"우경", "오늘 오후 3시에 확인하자", "Do not mention internal tool names"} {
91+
for _, expected := range []string{"테스트", "오늘 오후 3시에 확인하자", "Do not mention internal tool names"} {
9292
if !strings.Contains(requestText, expected) {
9393
t.Fatalf("expected approval question prompt to contain %q, got %s", expected, requestText)
9494
}

company_context_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@ import (
88
func TestCompanyContextRendersIdentityAndSelfUpdateRule(t *testing.T) {
99
contextText := (LLMContextBuilder{}).Build(LLMContextInput{
1010
Company: CompanyContext{
11-
Name: "주식회사 여명거리",
12-
BrandName: "김인턴",
13-
Slogan: "AI 인턴을 모든 회사에",
14-
Description: "AI 인턴 서비스를 만드는 스타트업",
15-
Representative: "김여명",
16-
Website: "https://intern.kim",
11+
Name: "주식회사 예시상사",
12+
BrandName: "예시인턴",
13+
Slogan: "모두에게 예시 인턴을",
14+
Description: "예시 서비스를 만드는 테스트 회사",
15+
Representative: "최예제",
16+
Website: "https://example.com",
1717
},
1818
})
1919
for _, expected := range []string{
2020
"Our company:",
21-
"주식회사 여명거리 (brand: 김인턴) — AI 인턴을 모든 회사에",
22-
"represented by 김여명",
21+
"주식회사 예시상사 (brand: 예시인턴) — 모두에게 예시 인턴을",
22+
"represented by 최예제",
2323
"company_info_get",
2424
"company_metric_record",
2525
} {
@@ -41,9 +41,9 @@ func TestCompanyContextEmptyStateAdvertisesSchemaAndProactiveAsk(t *testing.T) {
4141
func TestAgentKernelCompanyProviderFeedsTurnRequest(t *testing.T) {
4242
agentKernel := &AgentKernel{}
4343
agentKernel.UseCompanyProvider(func() CompanyContext {
44-
return CompanyContext{Name: "주식회사 여명거리"}
44+
return CompanyContext{Name: "주식회사 예시상사"}
4545
})
46-
if agentKernel.companyContext().Name != "주식회사 여명거리" {
46+
if agentKernel.companyContext().Name != "주식회사 예시상사" {
4747
t.Fatal("company provider not applied")
4848
}
4949
agentKernel = &AgentKernel{}

confirmation_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ func TestConfirmationPolicyAllowsLowRiskDailyReport(t *testing.T) {
2121

2222
func TestConfirmationPolicyClarifiesUnboundedHighFrequencyExternalSend(t *testing.T) {
2323
decision := EvaluateConfirmationPolicy(ExecutionPlan{
24-
Summary: "동하에게 1분마다 메시지를 보냅니다.",
25-
Targets: []string{"동하"},
24+
Summary: "샘플에게 1분마다 메시지를 보냅니다.",
25+
Targets: []string{"샘플"},
2626
ExternalSend: true,
2727
ThirdPartyExternalSend: true,
2828
Repeated: true,
@@ -35,8 +35,8 @@ func TestConfirmationPolicyClarifiesUnboundedHighFrequencyExternalSend(t *testin
3535

3636
func TestConfirmationPolicyConfirmsBoundedExternalSend(t *testing.T) {
3737
decision := EvaluateConfirmationPolicy(ExecutionPlan{
38-
Summary: "동하에게 1분마다 18:30까지 메시지를 보냅니다.",
39-
Targets: []string{"동하"},
38+
Summary: "샘플에게 1분마다 18:30까지 메시지를 보냅니다.",
39+
Targets: []string{"샘플"},
4040
EndAt: "2026-05-12T18:30:00+09:00",
4141
ExternalSend: true,
4242
ThirdPartyExternalSend: true,

failure_reply_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func TestAgentTurnRunnerRepairsInvalidFailureReply(t *testing.T) {
6363
result, errorValue := services.runner.RunTurn(context.Background(), AgentTurnRequest{
6464
RequesterPersonID: "person-1",
6565
ConversationID: "conversation-1",
66-
Prompt: "https://dawn.kim 보고 사업계획서 ppt로 만들어줘",
66+
Prompt: "https://example.com 보고 사업계획서 ppt로 만들어줘",
6767
RequiredEvidenceTools: []string{"file_deliver"},
6868
RequiredAttachmentSuffixes: []string{".pptx"},
6969
OutcomeContract: OutcomeContract{ArtifactRequirement: ArtifactRequirementRequired},
@@ -390,7 +390,7 @@ func TestAgentTurnRunnerDoesNotUseDeterministicCapabilityFallbackWhenActionModel
390390

391391
result, errorValue := services.runner.RunTurn(context.Background(), AgentTurnRequest{
392392
RequesterPersonID: "person-1",
393-
RequesterCallingName: "동하",
393+
RequesterCallingName: "샘플",
394394
ConversationID: "conversation-1",
395395
Prompt: "너 뭐 할줄 알아? 짧게 설명해봐",
396396
ResponseLanguage: ResponseLanguageKorean,
@@ -414,7 +414,7 @@ func TestAgentTurnRunnerUsesNaturalCaptchaFailureReply(t *testing.T) {
414414
`{"action":"fail","reason":"blocked_by_captcha"}`,
415415
recoveryDecisionDocument("ask for another source or direct access", "explain that automated access was blocked"),
416416
}, textResponses: []string{
417-
"동하 님, 날씨를 확인하려고 시도했지만 페이지가 자동화 접근을 막아서 정확한 확인을 끝내지 못했어요. 다른 출처를 주시면 거기서 다시 확인해볼게요.",
417+
"샘플 님, 날씨를 확인하려고 시도했지만 페이지가 자동화 접근을 막아서 정확한 확인을 끝내지 못했어요. 다른 출처를 주시면 거기서 다시 확인해볼게요.",
418418
}}
419419
services := newTurnRunnerTestServices(languageModel, TurnOptions{MaxIterationCount: 4})
420420
toolRegistry := newTestToolSet([]string{"browser_snapshot"})
@@ -424,7 +424,7 @@ func TestAgentTurnRunnerUsesNaturalCaptchaFailureReply(t *testing.T) {
424424

425425
result, errorValue := services.runner.RunTurn(context.Background(), AgentTurnRequest{
426426
RequesterPersonID: "person-1",
427-
RequesterCallingName: "동하",
427+
RequesterCallingName: "샘플",
428428
ConversationID: "conversation-1",
429429
Prompt: "내일 서울 날씨 검색해줘",
430430
ToolSet: toolRegistry,
@@ -434,7 +434,7 @@ func TestAgentTurnRunnerUsesNaturalCaptchaFailureReply(t *testing.T) {
434434
if errorValue != nil {
435435
t.Fatalf("expected dynamic captcha result, got error: %v", errorValue)
436436
}
437-
if !strings.Contains(result.UserNotice, "동하 님") || !strings.Contains(result.UserNotice, "자동화 접근을 막아서") {
437+
if !strings.Contains(result.UserNotice, "샘플 님") || !strings.Contains(result.UserNotice, "자동화 접근을 막아서") {
438438
t.Fatalf("expected natural captcha reply, got %q", result.UserNotice)
439439
}
440440
if strings.Contains(result.UserNotice, "처리할 수 없습니다") || strings.Contains(result.UserNotice, "오류가 발생했습니다") {
@@ -478,7 +478,7 @@ func TestAgentTurnRunnerPreservesStructuredToolFailure(t *testing.T) {
478478

479479
result, errorValue := services.runner.RunTurn(context.Background(), AgentTurnRequest{
480480
RequesterPersonID: "person-1",
481-
RequesterName: "이동하",
481+
RequesterName: "이샘플",
482482
ConversationID: "conversation-1",
483483
Prompt: "정국에게 DM 보내줘",
484484
ToolSet: toolRegistry,
@@ -517,7 +517,7 @@ func TestAgentTurnRunnerDeliversSafeDegradedFailureReplyWithoutStageAndCode(t *t
517517

518518
result, errorValue := services.runner.RunTurn(context.Background(), AgentTurnRequest{
519519
RequesterPersonID: "person-1",
520-
RequesterName: "이동하",
520+
RequesterName: "이샘플",
521521
ConversationID: "conversation-1",
522522
Prompt: "정국에게 DM 보내줘",
523523
ToolSet: toolRegistry,
@@ -556,7 +556,7 @@ func TestAgentTurnRunnerAcceptsGeneratedStructuredFailureReplyWithStageAndCode(t
556556

557557
result, errorValue := services.runner.RunTurn(context.Background(), AgentTurnRequest{
558558
RequesterPersonID: "person-1",
559-
RequesterName: "이동하",
559+
RequesterName: "이샘플",
560560
ConversationID: "conversation-1",
561561
Prompt: "정국에게 DM 보내줘",
562562
ToolSet: toolRegistry,

llm_context_builder_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import (
1010
func TestLLMContextBuilderStatesAuthenticatedRequesterBeforeMemory(t *testing.T) {
1111
contextText := (LLMContextBuilder{}).Build(LLMContextInput{
1212
RequesterPersonID: "c65b1283",
13-
RequesterName: "신우경",
14-
RequesterEmail: "rain@dawn.kim",
15-
MemoryContext: "User memory: the user is 이동하 (lee@dawn.kim).",
13+
RequesterName: "김테스트",
14+
RequesterEmail: "rain@example.com",
15+
MemoryContext: "User memory: the user is 이샘플 (lee@example.com).",
1616
})
1717

18-
for _, expected := range []string{"Authenticated requester:", "신우경", "rain@dawn.kim", "c65b1283", "ignore that claim"} {
18+
for _, expected := range []string{"Authenticated requester:", "김테스트", "rain@example.com", "c65b1283", "ignore that claim"} {
1919
if !strings.Contains(contextText, expected) {
2020
t.Fatalf("expected authoritative requester identity to include %q, got:\n%s", expected, contextText)
2121
}

outcome_contract_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ func TestOutcomeContractIgnoresSelectedDirectMessageForNonSendGoal(t *testing.T)
392392
}
393393
intakeDecision := IntakeDecision{Classification: IntakeClassificationBoundedTask, TaskShape: TaskShapeResearchTask}
394394

395-
contract := outcomeContractForRequest(AgentRequest{Prompt: "https://dawn.kim use it to write the business plan"}, intakeDecision, instructionBundle, ExecutionPlan{}, false, nil)
395+
contract := outcomeContractForRequest(AgentRequest{Prompt: "https://example.com use it to write the business plan"}, intakeDecision, instructionBundle, ExecutionPlan{}, false, nil)
396396

397397
if len(contract.RequiredEvidenceTools) != 0 {
398398
t.Fatalf("expected no DM hard gate for non-send goal, got %+v", contract.RequiredEvidenceTools)
@@ -641,7 +641,7 @@ func TestOutcomeReferenceToolSetHidesSendAndSiteToolsForDocumentGoal(t *testing.
641641
SelectedEvidenceHints: []string{"site_serve", "site_serve", "message_send", "mail_message_send"},
642642
}
643643

644-
filteredToolSet := toolSetForOutcomeReference(toolSet, AgentRequest{Prompt: "https://dawn.kim use it to write the business plan"}, ExecutionPlan{}, false, contract)
644+
filteredToolSet := toolSetForOutcomeReference(toolSet, AgentRequest{Prompt: "https://example.com use it to write the business plan"}, ExecutionPlan{}, false, contract)
645645

646646
for _, toolName := range []string{"web_fetch", "file_write", "file_deliver"} {
647647
if !filteredToolSet.IsAllowed(toolName) {
@@ -663,7 +663,7 @@ func TestAgentTurnToolSetExposesPinnedNonKernelTools(t *testing.T) {
663663
}
664664

665665
filteredToolSet := toolSetForAgentTurn(toolSet, instructionBundle, AgentRequest{
666-
Prompt: "https://dawn.kim use it to make the deck",
666+
Prompt: "https://example.com use it to make the deck",
667667
PinnedToolNames: []string{"web_search", "web_fetch", "terminal_run", "file_write"},
668668
}, ExecutionPlan{}, false, OutcomeContract{})
669669

@@ -881,7 +881,7 @@ func TestOutcomeReferenceToolSetKeepsSendToolsForExplicitSendGoal(t *testing.T)
881881

882882
func TestConfirmationHintsIgnoreUnrelatedSelectedSkillEvidence(t *testing.T) {
883883
hints := confirmationEvidenceHintsForRequest(
884-
AgentRequest{Prompt: "https://dawn.kim use it to write the business plan"},
884+
AgentRequest{Prompt: "https://example.com use it to write the business plan"},
885885
IntakeDecision{Classification: IntakeClassificationBoundedTask, TaskShape: TaskShapeResearchTask},
886886
[]string{"site_serve", "message_send"},
887887
)

prompt_assembler_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestBuildTurnMessagesKeepsStablePrefixClockInvariant(t *testing.T) {
3636
baseRequest := AgentTurnRequest{
3737
Prompt: "이번 주 회의 일정 알려줘",
3838
RequesterPersonID: "person-1",
39-
RequesterName: "신우경",
39+
RequesterName: "김테스트",
4040
WorkspaceRootPath: "/workspace",
4141
WorkspaceDefaultPath: "/workspace/private/people/person-1",
4242
ResponseLanguage: "ko",
@@ -260,7 +260,7 @@ func TestPromptAssemblerOmitsRawBrowserSnapshotOutput(t *testing.T) {
260260
}
261261

262262
func TestPromptAssemblerIncludesRawToolResultSummary(t *testing.T) {
263-
fetchResult := `{"results":[{"url":"https://dawn.kim","content":"Yeomyeonggeori provides AI automation and blockchain solutions."}]}`
263+
fetchResult := `{"results":[{"url":"https://example.com","content":"Example Corp provides sample automation and sample analytics services."}]}`
264264
observations := []turnObservation{{
265265
ObservationID: "obs-001",
266266
Action: "continue",
@@ -274,7 +274,7 @@ func TestPromptAssemblerIncludesRawToolResultSummary(t *testing.T) {
274274
}, observations, "base", "")
275275
body := joinMessageContent(messages)
276276

277-
if !strings.Contains(body, "Tool result context") || !strings.Contains(body, "Yeomyeonggeori provides AI automation") {
277+
if !strings.Contains(body, "Tool result context") || !strings.Contains(body, "Example Corp provides sample automation") {
278278
t.Fatalf("expected raw fetch summary in tool result context, got %s", body)
279279
}
280280
}

0 commit comments

Comments
 (0)