diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e0438..e3719eb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A person can set standing instructions that every coworker follows + +Settings now has a box for standing instructions: one piece of text per person, saved once and +spliced into every built-in coworker's prompt, in every channel, on every run, including the runs a +routine starts overnight. It is the place for what is true of every task rather than of any one of +them, such as how somebody wants to be written to or what their company is and is not to be called. +A coworker's role still decides what it does; these decide how it does it, and the prompt says so, +so an instruction cannot quietly redefine what a coworker is for. + +Instructions belong to the person who wrote them. Nobody, administrators included, can read or set +somebody else's, and they are deleted with the account. A coworker running at a remote AG-UI +endpoint is not sent them, since this deployment does not compose that prompt. Nothing is added to +any prompt until somebody writes something, so a deployment where nobody uses this behaves exactly +as before. + +This adds migration `0026_user_instructions`, which creates one table. + ### Coworkers are made in a wizard and managed in a dialog Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, diff --git a/app/src/components/settings/standing-instructions.tsx b/app/src/components/settings/standing-instructions.tsx new file mode 100644 index 000000000..c4ea2c32e --- /dev/null +++ b/app/src/components/settings/standing-instructions.tsx @@ -0,0 +1,115 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { saveInstructionsMutationOptions } from "@/lib/settings/mutations"; +import { + INSTRUCTIONS_LIMIT, + instructionsQueryOptions, +} from "@/lib/settings/queries"; +import { queryClient } from "@/query-client"; + +/** + * One box, applied to every coworker in every channel. + * + * WHAT IT IS FOR, said on the screen rather than left to be guessed. A coworker's role and this are + * both durable instructions and a person meeting them for the first time has no reason to know which + * belongs where, so the description draws the line the prompt draws: the role says what a coworker + * is for, this says how the person wants things done. Without that sentence the obvious mistake is + * to write a job description here and get it applied to every coworker at once. + */ +export function StandingInstructions() { + const stored = useQuery(instructionsQueryOptions()); + const save = useMutation(saveInstructionsMutationOptions(queryClient)); + + const [draft, setDraft] = useState(null); + const [problem, setProblem] = useState(null); + const [saved, setSaved] = useState(false); + + /* + * The box holds the stored text until somebody types, and their typing after that. + * + * Null is "has not been edited", which is not the same as "is empty": seeding the state with "" + * and then filling it in when the read lands would overwrite whatever they had already started + * typing into a box that was ready before the network was. + */ + useEffect(() => { + if (stored.data !== undefined && draft === null) setDraft(stored.data); + }, [stored.data, draft]); + + const text = draft ?? stored.data ?? ""; + const over = text.trim().length > INSTRUCTIONS_LIMIT; + const unchanged = stored.data !== undefined && text === stored.data; + + const submit = () => { + setProblem(null); + setSaved(false); + save.mutate(text, { + onError: (thrown: Error) => setProblem(thrown.message), + /* The server trims, so the box settles to what was actually stored rather than what was sent. */ + onSuccess: (asStored) => { + setDraft(asStored); + setSaved(true); + }, + }); + }; + + return ( + + {stored.isPending ? null : stored.error ? ( +

+ {stored.error.message} +

+ ) : ( +
+