bluecollar

Quickstart

The module needs Go 1.26 and depends on github.com/google/jsonschema-go and github.com/ergochat/readline. The ACP agent in cmd/bluecollar-acp is a second…

Build

go build ./...
go test ./...

The module needs Go 1.26 and depends on github.com/google/jsonschema-go and github.com/ergochat/readline. The ACP agent in cmd/bluecollar-acp is a second module, so its protocol dependencies stay out of anything that embeds the loop.

Serve a model locally

OLLAMA_CONTEXT_LENGTH=32768 ollama serve &
ollama pull qwen3.5:4b

Ollama loads a model with a 4096-token window unless told otherwise. The loop's instructions and a reasoning model's thinking do not fit in that, and the turn fails with finish reason length.

Run the command-line runner

go run ./cmd/bluecollar --model qwen3.5:4b "In one sentence, what is a POSIX user?"

cmd/bluecollar talks to any OpenAI-compatible endpoint (--endpoint, default http://127.0.0.1:11434/v1) and prints the ledger to stderr as the turn runs. It registers bash, file_read, write, edit, image_read and plan scoped to --workspace, plus equip when a decision model is configured. With a prompt it runs one turn and exits; with no arguments and a terminal it becomes a conversation.

flageffect
--modelthe model to ask, or BLUECOLLAR_MODEL
--endpoint, --api-keythe chat completions endpoint and its bearer token
--workspacethe directory shell commands and file tools work in
--exec-prefixa wrapper for every shell command, such as docker exec -i <container>
--without-toolsno tools at all, to watch the loop reason
--without-intakeskip intake and start a low task directly
--timeouthow long one turn may run, default five minutes
--tracewrite the run as one file, JSON when the path ends in .json and Markdown otherwise
--metricswrite what the turn cost as bench.RunMetrics JSON
--record-tape, --replay-taperecord every model request and answer, or answer from a recording with no endpoint

Intake needs a decision model, read from BLUECOLLAR_DECISION_ENDPOINT, BLUECOLLAR_DECISION_API_KEY and BLUECOLLAR_DECISION_MODEL. Without one the runner says so on stderr and starts the task anyway. A trace keeps everything the task carried, so read it before sending it anywhere. A tape is for replaying a run that went wrong; it is never evidence that the agent works.

Embed the loop

examples/clock gives the model one tool and asks it the time in Paris.

go run ./examples/clock
# The current time in Paris is Thursday, September 24, 2026 at 2:42 AM.
type clockInput struct {
	TimeZone string `json:"timeZone"`
}

type clockOutput struct {
	Time string `json:"time"`
}

func main() {
	ctx := context.Background()

	kernel := loop.NewAgentKernel(taskstate.NewTaskRunService(taskstate.NewTaskEventService()), taskstate.NewTaskStepService())
	kernel.UseLanguageModelProvider(openaicompatible.NewProvider("http://127.0.0.1:11434/v1", "", "qwen3.5:4b"))

	tools := toolcontract.NewToolSet(nil)
	toolcontract.RegisterToolFunction(tools, toolcontract.ToolFunction[clockInput, clockOutput]{
		Definition: toolcontract.ToolDefinition{
			Name:            "time_get",
			Description:     "Get the current date and time in one time zone.",
			Visibility:      toolcontract.ToolVisibilityModel,
			SideEffectClass: toolcontract.ToolSideEffectRead,
			InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["timeZone"],
				"properties":{"timeZone":{"type":"string","description":"an IANA time zone, such as Europe/Paris"}}}`),
			ResultContract: &toolcontract.ToolResultContract{Schema: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["time"],
				"properties":{"time":{"type":"string"}}}`)},
		},
		Handler: func(_ context.Context, input clockInput) (clockOutput, error) {
			location, errorValue := time.LoadLocation(input.TimeZone)
			if errorValue != nil {
				return clockOutput{}, errorValue
			}
			return clockOutput{Time: time.Now().In(location).Format("Monday 2 January 2006, 15:04")}, nil
		},
	})

	startTask := agentcontract.TurnDecision{
		Route:             agentcontract.TurnRouteStartTask,
		Classification:    agentcontract.IntakeClassificationBoundedTask,
		TaskShape:         agentcontract.TaskShapeMaintenanceTask,
		TaskLevel:         agentcontract.TaskLevelLow,
		InitialToolNames:  []string{"time_get"},
		ExpectedToolCount: agentcontract.ExpectedToolCountOne,
	}
	result, errorValue := kernel.RunTurn(ctx, agentcontract.AgentTurnRequest{
		RequesterPersonID:       "person-1",
		RequesterName:           "Alex",
		ConversationID:          "conversation-1",
		Prompt:                  "What time is it in Paris right now?",
		ToolSet:                 tools,
		PrecomputedTurnDecision: &startTask,
	})
	if errorValue != nil {
		log.Fatal(errorValue)
	}
	fmt.Println(result.FinishMessage)
}
  • A tool reaches the model only when its descriptor is visible and carries a ResultContract, and the result is a JSON object.
  • RunTurn refuses a turn without PrecomputedTurnDecision. The example fills one in by hand, naming the tool the work needs; the next section has intake decide it.
  • The taskstate services keep everything in memory until a host that needs durability gives each one a repository through UseRepository.

Route, then run

request := agentcontract.AgentTurnRequest{
	RequesterPersonID: "person-1",
	RequesterName:     "Alex",
	ConversationID:    "conversation-1",
	Prompt:            "What time is it in Paris right now?",
	ToolSet:           tools,
}
planner := intake.NewDecisionPlanner(decisions.ConfiguredDecisionModel(os.Stderr), nil, nil)
router := intake.NewTurnRouter(openaicompatible.NewProvider("http://127.0.0.1:11434/v1", "", "qwen3.5:4b"), planner, agentcontract.IntakeOptions{IsEnabled: true})
decision, errorValue := router.Plan(ctx, agentcontract.AgentRequest{
	RequesterPersonID: request.RequesterPersonID,
	RequesterName:     request.RequesterName,
	ConversationID:    request.ConversationID,
	Prompt:            request.Prompt,
	ToolSet:           request.ToolSet,
})
if errorValue != nil {
	decision = agentcontract.TurnDecision{
		Route:            agentcontract.TurnRouteStartTask,
		Classification:   agentcontract.IntakeClassificationBoundedTask,
		TaskShape:        agentcontract.TaskShapeMaintenanceTask,
		TaskLevel:        agentcontract.TaskLevelLow,
		InitialToolNames: []string{"time_get"},
	}
}
request.PrecomputedTurnDecision = &decision

result, errorValue := kernel.RunTurn(ctx, request)

RunTurn fails a turn that arrives without PrecomputedTurnDecision: the host routes before it hands a turn to the harness. result.TaskRun.Status is where the task ended and result.FinishMessage is what the requester reads.

On this page