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) }