agent build

This commit is contained in:
2026-08-28 12:21:44 +05:30
parent b6f8655909
commit f7df96c973
138 changed files with 24164 additions and 207 deletions

View File

@@ -0,0 +1,595 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// Assignments: the first tools that change something, and the two lookups that
// make changing something possible.
//
// §4 has a line that dictates the shape of this file: "a tool description that
// requires the model to guess an ID it has not been given is a design bug. Add
// a lookup tool instead." Every read tool built so far returns aggregates —
// counts, averages, the weakest five — and none of them return a row id,
// deliberately: an id in an analytics answer is noise. But an assignment names
// a posting and a person, so the write is unusable until the model has a
// legitimate way to learn those two things.
//
// Hence three tools, in the order an agent actually uses them:
//
// open_positions → which roles need people, with their ids
// available_workers → who is free in that window, with their emails
// assign_worker → put one on the other, once a person has said yes
//
// The alternative — a single write that accepts a worker's name and resolves it
// itself — reads as friendlier and is considerably worse. Two people called
// Chen makes it ambiguous, and the disambiguation would happen inside a write,
// after approval, with no one watching.
/* ── Lookup: open positions ─────────────────────────────────────────────── */
type openPositionsInput struct {
Limit int `json:"limit"`
}
// OpenPositions lists roles that still need people, with the ids to fill them.
func OpenPositions(db repo.Querier) Tool {
return Tool{
Name: "open_positions",
Description: "List the roles that are currently open, with how many people each " +
"needs, how many are already assigned, and how many are still to fill. " +
"Returns an id for each role. Use this before assigning anyone: it is the " +
"only way to learn a role's id, and an id must never be guessed.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": "How many roles to list, most urgent first. Defaults to 20.",
},
},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "job-postings", "p")
if denied != nil {
return *denied
}
var in openPositionsInput
if len(inputs) > 0 {
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
}
limit := in.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
// Only roles that can actually be staffed. A draft has not been
// agreed, a paused role has been stopped on purpose, and a closed
// one is history — offering any of them as assignable would invite
// the agent to staff a role nobody is hiring for.
q.raw("p.status = 'active'")
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx, `
SELECT p.id::text, p.title, p.role_category, p.location,
p.headcount, p.priority::text, p.start_date,
(SELECT count(*) FROM assignments a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id
AND a.status = 'active')
FROM job_postings p
WHERE `+q.clause()+`
ORDER BY p.priority DESC, p.created_date ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
defer rows.Close()
positions := []map[string]any{}
for rows.Next() {
var (
id, title, category, location, priority string
headcount int
startDate *time.Time
assigned int
)
if err := rows.Scan(&id, &title, &category, &location,
&headcount, &priority, &startDate, &assigned); err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
stillToFill := headcount - assigned
if stillToFill < 0 {
stillToFill = 0
}
p := map[string]any{
"id": id, "title": title, "priority": priority,
"headcount": headcount, "assigned": assigned, "stillToFill": stillToFill,
}
if category != "" {
p["roleCategory"] = category
}
if location != "" {
p["location"] = location
}
if startDate != nil {
p["startDate"] = startDate.Format("2006-01-02")
}
positions = append(positions, p)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
data := map[string]any{"positions": positions, "count": len(positions)}
if len(positions) == 0 {
data["note"] = "No roles are open. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Lookup: available workers ──────────────────────────────────────────── */
type availableWorkersInput struct {
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
Limit int `json:"limit"`
}
// AvailableWorkers lists workers with no clashing assignment in a window.
//
// "Available" here means one specific, checkable thing: no active assignment
// overlapping the window. It does not mean willing, qualified, or within their
// contracted hours. The description says so, because a model given a tool
// called `available_workers` will otherwise report its output as availability
// in the ordinary sense of the word, and a manager will read it that way.
func AvailableWorkers(db repo.Querier) Tool {
return Tool{
Name: "available_workers",
Description: "List workers who have no clashing assignment in a given window, " +
"best-scoring first, with the email needed to assign them. Availability here " +
"means only that nothing else is booked over that window — it does not mean " +
"the person has agreed, is qualified for the role, or is within their hours. " +
"Say so when reporting it.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"starts_at": map[string]any{
"type": "string",
"description": "When the work starts, as an RFC 3339 timestamp " +
"(for example 2026-09-12T18:00:00Z). Required.",
},
"ends_at": map[string]any{
"type": "string",
"description": "When the work ends, as an RFC 3339 timestamp. " +
"Omit for open-ended work.",
},
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": "How many workers to list. Defaults to 10.",
},
},
"required": []string{"starts_at"},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "worker-profiles", "w")
if denied != nil {
return *denied
}
var in availableWorkersInput
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
if bad != nil {
return *bad
}
limit := in.Limit
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
// The clash test is a NOT EXISTS against the same tenant, so it is
// a pre-filter like every other predicate here rather than a list
// fetched and then thinned in Go.
args := append(append([]any{}, q.args...), starts, ends, limit)
startIdx, endIdx, limIdx := len(args)-2, len(args)-1, len(args)
rows, err := db.Query(ctx, fmt.Sprintf(`
SELECT w.full_name, w.email::text, nullif(w.krow_score, 0),
nullif(w.reliability_score, 0), nullif(w.experience_years, 0),
w.current_position
FROM worker_profiles w
WHERE %s
AND NOT EXISTS (
SELECT 1 FROM assignments a
WHERE a.org_id = w.org_id
AND a.worker_email = w.email
AND a.status = 'active'
AND tstzrange(a.starts_at, coalesce(a.ends_at, 'infinity'::timestamptz))
&& tstzrange($%d, coalesce($%d::timestamptz, 'infinity'::timestamptz)))
ORDER BY w.krow_score DESC NULLS LAST, w.reliability_score DESC NULLS LAST
LIMIT $%d`, q.clause(), startIdx, endIdx, limIdx), args...)
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
defer rows.Close()
workers := []map[string]any{}
for rows.Next() {
// Pointers, so an unrated worker carries no rating rather than a
// 0 the model would read as the worst possible score. This list
// feeds assign_worker, so the distinction picks who gets offered.
var (
name, email, position string
krow, reliability, experience *int
)
if err := rows.Scan(&name, &email, &krow, &reliability, &experience, &position); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
w := map[string]any{"name": name, "email": email}
if krow != nil {
w["krowScore"] = *krow
}
if reliability != nil {
w["reliabilityScore"] = *reliability
}
if experience != nil {
w["experienceYears"] = *experience
}
if position != "" {
w["currentPosition"] = position
}
workers = append(workers, w)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
data := map[string]any{
"window": windowLabel(starts, ends),
"workers": workers,
"count": len(workers),
"meaning": "No clashing assignment in this window. Not a statement that " +
"they have agreed or are qualified.",
}
if len(workers) == 0 {
data["note"] = "Nobody is free in that window. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Write: assign a worker ─────────────────────────────────────────────── */
type assignWorkerInput struct {
JobPostingID string `json:"job_posting_id"`
WorkerEmail string `json:"worker_email"`
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
}
// AssignWorker puts a worker on a role. The first tool in this service that
// changes anything.
//
// The pattern every future write should copy is the split between Confirm and
// Handler, and specifically what is duplicated across them. Both authorize.
// Both resolve the posting and the worker. Both check for a clash. That looks
// like repetition and is not: the renderer runs to describe, and the handler
// runs after a person has read that description and agreed — with an unbounded
// gap in between, during which somebody else may have taken the same shift.
//
// So the renderer's clash check produces a WARNING, and the handler's produces
// a REFUSAL. A description is about the moment it was written; a write is about
// the moment it happens.
func AssignWorker(db repo.Querier) Tool {
return Tool{
Name: "assign_worker",
Description: "Assign a worker to an open role for a given period. This creates a " +
"real assignment: the person is scheduled to work. Requires a role id from " +
"open_positions and a worker email from available_workers — never invent " +
"either. A person must approve before this takes effect.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"job_posting_id": map[string]any{
"type": "string",
"description": "The role's id, exactly as returned by open_positions.",
},
"worker_email": map[string]any{
"type": "string",
"description": "The worker's email, exactly as returned by available_workers.",
},
"starts_at": map[string]any{
"type": "string",
"description": "When the work starts, as an RFC 3339 timestamp. Required.",
},
"ends_at": map[string]any{
"type": "string",
"description": "When the work ends, as an RFC 3339 timestamp. Omit for open-ended.",
},
},
"required": []string{"job_posting_id", "worker_email", "starts_at"},
"additionalProperties": false,
},
Effect: EffectWrite,
MaxResultBytes: DefaultMaxResultBytes,
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
plan, denied := planAssignment(ctx, db, tc, inputs)
if denied != nil {
return nil, denied
}
details := []Detail{
{Label: "Worker", Value: plan.workerName},
{Label: "Role", Value: plan.postingTitle},
{Label: "Period", Value: windowLabel(plan.starts, plan.ends)},
}
if plan.location != "" {
details = append(details, Detail{Label: "Location", Value: plan.location})
}
details = append(details, Detail{
Label: "Role filled",
Value: fmt.Sprintf("%d of %d, this would make %d",
plan.assigned, plan.headcount, plan.assigned+1),
})
var warnings []string
if plan.clashes > 0 {
warnings = append(warnings, fmt.Sprintf(
"%s already has %s over this period. Assigning them will double-book.",
plan.workerName, plural(plan.clashes, "assignment", "assignments")))
}
if plan.assigned >= plan.headcount {
warnings = append(warnings, fmt.Sprintf(
"%s already has all %s it asked for. This would go over headcount.",
plan.postingTitle, plural(plan.headcount, "person", "people")))
}
if plan.starts.Before(time.Now()) {
warnings = append(warnings, "This period starts in the past.")
}
return &Confirmation{
Title: fmt.Sprintf("Assign %s to %s", plan.workerName, plan.postingTitle),
Summary: fmt.Sprintf(
"%s will be scheduled to work %s as %s. They will appear on the roster "+
"for this role and count towards its headcount.",
plan.workerName, windowLabel(plan.starts, plan.ends), plan.postingTitle),
Details: details,
Warnings: warnings,
}, nil
},
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
plan, denied := planAssignment(ctx, db, tc, inputs)
if denied != nil {
return *denied
}
// Re-checked here, not merely described above. The approval was
// given against a picture of the world that is now some minutes
// old, and a double-booking created between the asking and the
// answering is one nobody agreed to.
if plan.clashes > 0 {
return Failf(CodeFailed,
"%s was booked over this period since this was approved; nothing was assigned",
plan.workerName)
}
var id string
err := db.QueryRow(ctx, `
INSERT INTO assignments
(org_id, job_posting_id, worker_profile_id, worker_email,
worker_name, starts_at, ends_at, status, source, match_score)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, 'active', 'agent', $8)
RETURNING id::text`,
tc.OrgID(), plan.postingID, plan.workerProfileID, plan.workerEmail,
plan.workerName, plan.starts, nullableTime(plan.ends), plan.matchScore,
).Scan(&id)
if err != nil {
return Failf(CodeFailed, "the assignment could not be created")
}
return OK(map[string]any{
"assignmentId": id,
"worker": plan.workerName,
"role": plan.postingTitle,
"period": windowLabel(plan.starts, plan.ends),
"status": "active",
"confirmed": true,
})
},
}
}
// assignmentPlan is everything both Confirm and Handler need, resolved once.
type assignmentPlan struct {
postingID string
postingTitle string
location string
headcount int
assigned int
workerProfileID *string
workerEmail string
workerName string
matchScore *int
starts time.Time
ends *time.Time
clashes int
}
// planAssignment authorizes, validates and resolves an assign_worker call.
//
// Shared by the renderer and the handler so that the thing described and the
// thing done are resolved by the same code. Two separate resolutions would
// drift, and the drift would land exactly where nobody looks: between what a
// person approved and what then happened.
//
// Every refusal is the same opaque Denied(). A posting id that belongs to
// another tenant, one that does not exist, and one this caller may not see are
// all indistinguishable — otherwise assign_worker becomes a way to ask whether
// a given uuid is real.
func planAssignment(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*assignmentPlan, *Result) {
// Create, not List. `assignments` lists to everyone and creates for
// operators only, so a talent caller is refused here even though they may
// read their own roster perfectly well.
if _, denied := authorizeOp(tc, "assignments", domain.OpCreate, ""); denied != nil {
return nil, denied
}
var in assignWorkerInput
if err := json.Unmarshal(inputs, &in); err != nil {
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
return nil, &bad
}
if strings.TrimSpace(in.JobPostingID) == "" || strings.TrimSpace(in.WorkerEmail) == "" {
bad := Failf(CodeInvalidInput, "a role id and a worker email are both required")
return nil, &bad
}
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
if bad != nil {
return nil, bad
}
plan := &assignmentPlan{starts: starts, ends: ends}
// The posting, behind the caller's own read predicate. Referencing a row
// the caller could not have read would confirm it exists.
pq, denied := authorizeAs(tc, "job-postings", "p")
if denied != nil {
return nil, denied
}
pq.eq("id::text", strings.TrimSpace(in.JobPostingID))
pq.raw("p.status = 'active'")
err := db.QueryRow(ctx, `
SELECT p.id::text, p.title, p.location, p.headcount,
(SELECT count(*) FROM assignments a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.status = 'active')
FROM job_postings p
WHERE `+pq.clause(), pq.args...,
).Scan(&plan.postingID, &plan.postingTitle, &plan.location, &plan.headcount, &plan.assigned)
if err != nil {
denied := Denied()
return nil, &denied
}
// The worker, likewise.
wq, denied := authorizeAs(tc, "worker-profiles", "w")
if denied != nil {
return nil, denied
}
wq.eq("email", strings.TrimSpace(in.WorkerEmail))
var profileID string
if err := db.QueryRow(ctx, `
SELECT w.id::text, w.full_name, w.email::text, nullif(w.krow_score, 0)
FROM worker_profiles w
WHERE `+wq.clause()+`
LIMIT 1`, wq.args...,
).Scan(&profileID, &plan.workerName, &plan.workerEmail, &plan.matchScore); err != nil {
denied := Denied()
return nil, &denied
}
plan.workerProfileID = &profileID
if strings.TrimSpace(plan.workerName) == "" {
plan.workerName = plan.workerEmail
}
// Clashes: active assignments overlapping the window. Counted in SQL —
// fetching the person's roster and comparing in Go would be the same
// post-filter I2 forbids, and would read rows this call has no reason to.
if err := db.QueryRow(ctx, `
SELECT count(*) FROM assignments
WHERE org_id = $1::uuid AND worker_email = $2 AND status = 'active'
AND tstzrange(starts_at, coalesce(ends_at, 'infinity'::timestamptz))
&& tstzrange($3, coalesce($4::timestamptz, 'infinity'::timestamptz))`,
tc.OrgID(), plan.workerEmail, starts, nullableTime(ends),
).Scan(&plan.clashes); err != nil {
failed := Failf(CodeFailed, "the worker's existing assignments could not be read")
return nil, &failed
}
return plan, nil
}
/* ── Shared helpers ─────────────────────────────────────────────────────── */
// decodeWindow parses a caller-supplied period.
//
// RFC 3339 only, and validated here rather than at the database. A timestamp
// that reaches SQL as an uninterpretable string is a constraint violation
// wearing the costume of a tool failure, and the model cannot correct what it
// cannot read.
func decodeWindow(startsAt, endsAt string) (time.Time, *time.Time, *Result) {
starts, err := time.Parse(time.RFC3339, strings.TrimSpace(startsAt))
if err != nil {
bad := Failf(CodeInvalidInput,
"starts_at must be an RFC 3339 timestamp, for example 2026-09-12T18:00:00Z")
return time.Time{}, nil, &bad
}
if strings.TrimSpace(endsAt) == "" {
return starts, nil, nil
}
ends, err := time.Parse(time.RFC3339, strings.TrimSpace(endsAt))
if err != nil {
bad := Failf(CodeInvalidInput,
"ends_at must be an RFC 3339 timestamp, for example 2026-09-12T23:00:00Z")
return time.Time{}, nil, &bad
}
// The table has a CHECK for this. Caught here so the model gets a sentence
// it can act on rather than a constraint name it cannot.
if !ends.After(starts) {
bad := Failf(CodeInvalidInput, "ends_at must be after starts_at")
return time.Time{}, nil, &bad
}
return starts, &ends, nil
}
// windowLabel renders a period the way a person reads one.
func windowLabel(starts time.Time, ends *time.Time) string {
if ends == nil {
return starts.Format("Mon 2 Jan 2006, 15:04") + " onwards"
}
if ends.YearDay() == starts.YearDay() && ends.Year() == starts.Year() {
return fmt.Sprintf("%s–%s",
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("15:04"))
}
return fmt.Sprintf("%s – %s",
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("Mon 2 Jan 2006, 15:04"))
}
func nullableTime(t *time.Time) any {
if t == nil {
return nil
}
return *t
}
func plural(n int, one, many string) string {
if n == 1 {
return fmt.Sprintf("1 %s", one)
}
return fmt.Sprintf("%d %s", n, many)
}