Support configuring effort alongside model

This commit is contained in:
Ian Gulliver
2026-10-23 08:40:00 -07:00
parent 6bbae3fffe
commit d6d9dd86d9
40 changed files with 805 additions and 541 deletions
+16 -12
View File
@@ -488,17 +488,17 @@ final class ChatModel {
}
}
func send(sessionId: String?, text: String, images: [String]?, model: String?, firstModel: String, sendID: String) async throws {
func send(sessionId: String?, text: String, images: [String]?, model: String?, effort: String?, firstModel: String, firstEffort: String, sendID: String) async throws {
let api = store.api.client
switch kind {
case .lead:
_ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel, sendId: sendID))).accepted
_ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel, effort: sessionId != nil ? effort : firstEffort, sendId: sendID))).accepted
case .task(let id):
if closed {
guard let sessionId else {
throw ApiError(status: 0, message: "This task has no agent conversation to continue.")
}
let task = try await api.continueSession(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: team ? nil : model, sendId: sendID))).created.body.json
let task = try await api.continueSession(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: team ? nil : model, effort: team ? nil : effort, sendId: sendID))).created.body.json
store.focusTask(task.id)
return
}
@@ -510,10 +510,10 @@ final class ChatModel {
return
}
if let sessionId {
_ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model, sendId: sendID))).accepted
_ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model, effort: effort, sendId: sendID))).accepted
return
}
_ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images, sendId: sendID))).created
_ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, effort: firstEffort, images: images, sendId: sendID))).created
}
}
@@ -522,7 +522,7 @@ final class ChatModel {
let cancel = builder.showPending(leadSetup, [], sendID)
Task {
do {
try await send(sessionId: liveSession?.id, text: leadSetup, images: nil, model: nil, firstModel: store.defaultModel, sendID: sendID)
try await send(sessionId: liveSession?.id, text: leadSetup, images: nil, model: nil, effort: nil, firstModel: store.defaultModel, firstEffort: store.defaultEffort, sendID: sendID)
} catch {
if cancel() {
overlays.showAlert(errorText(error))
@@ -663,7 +663,7 @@ private struct MemberPicker: View {
PaneTabButton(label: s.state == .running ? "\(s.member) ●" : s.member, active: s.member == picked) {
store.pickedMembers[taskId] = s.member
}
.help("Show \(s.member)'s transcript (\(s.model.isEmpty ? s.vendor.rawValue : s.model), \(s.state.rawValue))")
.help("Show \(s.member)'s transcript (\(s.model.isEmpty ? s.vendor.rawValue : s.model)\(s.effort.isEmpty ? "" : " at \(effortLabel(s.effort))"), \(s.state.rawValue))")
}
}
}
@@ -781,8 +781,12 @@ struct CompactButton: View {
let disabled = sessionId == nil || state != .idle || sending
Menu {
ForEach(models, id: \.self) { m in
Button("Continue on \(m)") {
compact(m)
Menu("Continue on \(m)") {
ForEach(efforts(m), id: \.self) { e in
Button("At \(effortLabel(e))") {
compact(m, e)
}
}
}
}
} label: {
@@ -807,7 +811,7 @@ struct CompactButton: View {
.opacity(disabled ? 0.45 : 1)
.contentShape(Rectangle())
} primaryAction: {
compact(nil)
compact(nil, nil)
}
.menuStyle(.button)
.menuIndicator(.hidden)
@@ -820,14 +824,14 @@ struct CompactButton: View {
}
}
private func compact(_ model: String?) {
private func compact(_ model: String?, _ effort: String?) {
guard let sessionId else {
return
}
sending = true
Task {
do {
_ = try await store.api.client.compactSession(path: .init(sessionId: sessionId), body: .json(.init(model: model))).accepted
_ = try await store.api.client.compactSession(path: .init(sessionId: sessionId), body: .json(.init(model: model, effort: effort))).accepted
} catch {
overlays.showAlert(errorText(error))
sending = false
+54 -3
View File
@@ -80,6 +80,9 @@ func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]?
@MainActor
var modelChoices: [String: String] = [:]
@MainActor
var effortChoices: [String: String] = [:]
func vendorOf(_ model: String) -> String {
if model.hasPrefix("gpt-") {
return "codex"
@@ -90,6 +93,18 @@ func vendorOf(_ model: String) -> String {
return "claude"
}
func efforts(_ model: String) -> [String] {
vendorOf(model) == "codex" ? ["", "low", "medium", "high", "xhigh"] : ["", "low", "medium", "high", "xhigh", "max"]
}
func effortLabel(_ effort: String) -> String {
effort.isEmpty ? "default effort" : "\(effort) effort"
}
func effortFor(_ model: String, _ effort: String) -> String {
efforts(model).contains(effort) ? effort : ""
}
@MainActor
@Observable
final class ImageDrafts {
@@ -392,7 +407,9 @@ struct ComposerMenu: View {
let photos: Bool
var models: [String] = []
var selected = ""
var effort = ""
var choose: @MainActor @Sendable (String) -> Void = { _ in }
var chooseEffort: @MainActor @Sendable (String) -> Void = { _ in }
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var picking = false
@@ -419,6 +436,12 @@ struct ComposerMenu: View {
}
}
.pickerStyle(.menu)
Picker("Effort for the next turn", selection: Binding(get: { effort }, set: chooseEffort)) {
ForEach(efforts(selected), id: \.self) { option in
Text(effortLabel(option)).tag(option)
}
}
.pickerStyle(.menu)
}
} label: {
Image(systemName: "ellipsis")
@@ -548,6 +571,7 @@ struct ComposerView: View {
@Environment(\.inTask) private var inTask
@ViewState private var text = ""
@ViewState private var pick: String?
@ViewState private var effortPick: String?
@ViewState private var shownOverride: Bool?
@FocusState private var focused: Bool
@@ -579,19 +603,28 @@ struct ComposerView: View {
private struct ModelState {
let live: AgentSession?
let current: String
let currentEffort: String
let options: [String]
let modelKey: String
let selected: String
let selectedEffort: String
var changed: Bool {
selected != current || selectedEffort != currentEffort
}
}
private func modelState() -> ModelState {
let live = chat.closed ? chat.session : chat.liveSession
let current = live.map { $0.model.isEmpty ? chat.builder.context[$0.id]?.model ?? "" : $0.model } ?? ""
let currentEffort = live?.effort ?? ""
let offered = live.map { s in models.filter { vendorOf($0) == s.vendor.rawValue } } ?? models
let options = !current.isEmpty && !offered.contains(current) ? [current] + offered : offered
let modelKey = "message:\(live?.id ?? key)"
let fresh = live == nil ? store.defaultModel : options[0]
return ModelState(live: live, current: current, options: options, modelKey: modelKey, selected: pick ?? modelChoices[modelKey] ?? (current.isEmpty ? fresh : current))
let selected = pick ?? modelChoices[modelKey] ?? (current.isEmpty ? fresh : current)
let effort = effortPick ?? effortChoices[modelKey] ?? (live == nil ? store.defaultEffort : currentEffort)
return ModelState(live: live, current: current, currentEffort: currentEffort, options: options, modelKey: modelKey, selected: selected, selectedEffort: effortFor(selected, effort))
}
var body: some View {
@@ -668,6 +701,7 @@ struct ComposerView: View {
}
.onChange(of: m.modelKey) {
pick = nil
effortPick = nil
}
.onChange(of: overlays.leadFocus) {
if chat.kind == .lead {
@@ -702,9 +736,12 @@ struct ComposerView: View {
.buttonStyle(WebButtonStyle())
.help(shown ? "Hide the message box" : "Show the message box")
#if os(iOS)
ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected) { option in
ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected, effort: m.selectedEffort) { option in
pick = option
modelChoices[m.modelKey] = option
} chooseEffort: { option in
effortPick = option
effortChoices[m.modelKey] = option
}
#else
if !chat.team {
@@ -720,6 +757,18 @@ struct ComposerView: View {
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
Picker("", selection: Binding(get: { m.selectedEffort }, set: { v in
effortPick = v
effortChoices[m.modelKey] = v
})) {
ForEach(efforts(m.selected), id: \.self) { option in
Text(effortLabel(option)).tag(option)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Effort for the next turn")
}
#endif
}
@@ -799,9 +848,11 @@ struct ComposerView: View {
ids = try await uploadImages(store.api, imgs)
uploaded = true
}
try await chat.send(sessionId: sessionId, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected, sendID: sendID)
try await chat.send(sessionId: sessionId, text: value, images: ids, model: m.changed ? m.selected : nil, effort: m.changed ? m.selectedEffort : nil, firstModel: m.selected, firstEffort: m.selectedEffort, sendID: sendID)
modelChoices[m.modelKey] = nil
effortChoices[m.modelKey] = nil
pick = nil
effortPick = nil
drafts.endSend(sent, true)
return
} catch {
+15 -8
View File
@@ -48,12 +48,18 @@ struct SettingsView: View {
DeviceNotifications()
PrefsBox()
heading("Default model")
Text("New tasks you start from byre run on this model.")
Text("New tasks you start from byre run on this model, at this effort.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
ModelMenu(current: store.defaultModel, help: "Model for new tasks") { m in
try await store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json
HStack(spacing: 8) {
PickMenu(current: store.defaultModel, options: models, label: { $0 }, help: "Model for new tasks") { m in
try await store.api.client.updateDefaultModel(body: .json(.init(model: m, effort: effortFor(m, store.defaultEffort)))).ok.body.json
}
PickMenu(current: store.defaultEffort, options: efforts(store.defaultModel), label: effortLabel, help: "Effort for new tasks") { e in
try await store.api.client.updateDefaultModel(body: .json(.init(model: store.defaultModel, effort: e))).ok.body.json
}
}
.padding(.top, 8)
heading("Action button workflow")
Text("The iPhone's Action button, set to byre's Run workflow shortcut, opens a page to type a prompt and start this workflow with it. When more than one server has one set, the page asks which to start.")
.foregroundStyle(Palette.muted)
@@ -415,8 +421,10 @@ private struct SavedCommands: View {
}
}
private struct ModelMenu: View {
private struct PickMenu: View {
let current: String
let options: [String]
let label: (String) -> String
let help: String
let pick: (String) async throws -> User
@Environment(Store.self) private var store
@@ -424,8 +432,8 @@ private struct ModelMenu: View {
var body: some View {
Menu {
ForEach(models, id: \.self) { m in
Button(m) {
ForEach(options, id: \.self) { m in
Button(label(m)) {
Task {
do {
store.me = try await pick(m)
@@ -437,7 +445,7 @@ private struct ModelMenu: View {
}
} label: {
HStack(spacing: 8) {
Text(current)
Text(label(current))
Image(systemName: "chevron.up.chevron.down")
.font(Fonts.font(9, weight: 600))
}
@@ -450,6 +458,5 @@ private struct ModelMenu: View {
.menuIndicator(.hidden)
.fixedSize()
.help(help)
.padding(.top, 8)
}
}
+7 -3
View File
@@ -165,7 +165,7 @@ final class Store {
todoBusy[key] = discuss ? "discuss" : "start"
Task {
do {
_ = try await api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: defaultModel, discuss: discuss))).created
_ = try await api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: defaultModel, effort: defaultEffort, discuss: discuss))).created
} catch {
overlays.showAlert(errorText(error))
}
@@ -181,7 +181,7 @@ final class Store {
todoBusy[key] = "start"
Task {
do {
_ = try await api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: defaultModel))).created
_ = try await api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: defaultModel, effort: defaultEffort))).created
} catch {
overlays.showAlert(errorText(error))
}
@@ -199,7 +199,7 @@ final class Store {
let live = lead != nil && lead!.state != .completed
Task {
do {
_ = try await api.client.messageLead(body: .json(.init(text: "Let's discuss your suggestion \"\(title)\" before starting it:\n\n\(brief)", model: live ? nil : defaultModel))).accepted
_ = try await api.client.messageLead(body: .json(.init(text: "Let's discuss your suggestion \"\(title)\" before starting it:\n\n\(brief)", model: live ? nil : defaultModel, effort: live ? nil : defaultEffort))).accepted
} catch {
overlays.showAlert(errorText(error))
}
@@ -330,6 +330,10 @@ final class Store {
me!.defaultModel
}
var defaultEffort: String {
me!.defaultEffort
}
func loadMe() async throws {
me = try await api.client.getMe().ok.body.json
}
+1 -1
View File
@@ -334,7 +334,7 @@ final class SwitcherModel {
Task {
do {
let ids = try await uploadImages(store.api, imgs)
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(prompt: message.isEmpty ? nil : message, images: ids, model: store.defaultModel))).created.body.json
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(prompt: message.isEmpty ? nil : message, images: ids, model: store.defaultModel, effort: store.defaultEffort))).created.body.json
store.composerFocus = task.id
store.focusTask(task.id)
} catch {
+4 -2
View File
@@ -915,10 +915,12 @@ final class TranscriptBuilder {
case "session.created":
let model = str(p, "model") ?? ""
return note([.text("\(ev.actor) started a \(jsString(p["vendor"])) session\(model.isEmpty ? "" : " (\(model))")")])
let effort = str(p, "effort") ?? ""
let detail = [model, effort.isEmpty ? "" : effortLabel(effort)].filter { !$0.isEmpty }.joined(separator: ", ")
return note([.text("\(ev.actor) started a \(jsString(p["vendor"])) session\(detail.isEmpty ? "" : " (\(detail))")")])
case "session.model":
return note([.text("\(ev.actor) switched the model to \(jsString(p["model"]))")])
return note([.text("\(ev.actor) switched the model to \(jsString(p["model"])) at \(effortLabel(str(p, "effort") ?? ""))")])
case "session.state":
guard let error = str(p, "error"), !error.isEmpty else {
+19 -8
View File
@@ -16,6 +16,7 @@ final class StepNode: Identifiable {
var kind: Kind
var name = ""
var model = ""
var effort = ""
var prompt = ""
var weight = 0.0
var agents: [TeamMember]?
@@ -42,6 +43,7 @@ final class StepNode: Identifiable {
self.init(kind: .stage)
name = step.stage ?? ""
model = step.model ?? ""
effort = step.effort ?? ""
prompt = step.prompt ?? ""
agents = step.agents
}
@@ -52,7 +54,7 @@ final class StepNode: Identifiable {
let w = parent?.kind == .random ? weight : nil
return switch kind {
case .stage:
WorkflowStep(stage: name, model: agents == nil ? model : nil, prompt: prompt, weight: w, agents: agents)
WorkflowStep(stage: name, model: agents == nil ? model : nil, effort: agents == nil && !effort.isEmpty ? effort : nil, prompt: prompt, weight: w, agents: agents)
case .order:
WorkflowStep(order: children.map(\.step), weight: w)
case .parallel:
@@ -90,7 +92,7 @@ final class StepNode: Identifiable {
}
@MainActor
private func newStage(_ root: StepNode, _ model: String) -> StepNode {
private func newStage(_ root: StepNode, _ model: String, _ effort: String) -> StepNode {
let names = Set(root.stageNames())
var n = names.count + 1
while names.contains("Stage \(n)") {
@@ -99,6 +101,7 @@ private func newStage(_ root: StepNode, _ model: String) -> StepNode {
let s = StepNode(kind: .stage)
s.name = "Stage \(n)"
s.model = model
s.effort = effort
s.open = true
return s
}
@@ -251,7 +254,7 @@ struct WorkflowEditor: View {
}
.padding(.top, 12)
Button("Add stage") {
root.insert(newStage(root, store.defaultModel), at: root.children.count)
root.insert(newStage(root, store.defaultModel, store.defaultEffort), at: root.children.count)
}
.buttonStyle(SmallLinkButtonStyle())
.padding(.top, 8)
@@ -285,7 +288,7 @@ struct WorkflowEditor: View {
private func load() async {
guard let original else {
let r = StepNode(kind: .order)
r.insert(newStage(r, store.defaultModel), at: 0)
r.insert(newStage(r, store.defaultModel, store.defaultEffort), at: 0)
root = r
return
}
@@ -355,7 +358,7 @@ private struct StepRow: View {
if let agents = node.agents {
Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize()
} else {
ModelPicker(model: $node.model)
ModelPicker(model: $node.model, effort: $node.effort)
}
case .order:
Text("in order").foregroundStyle(Palette.muted)
@@ -389,7 +392,8 @@ private struct StepRow: View {
TextField("Agent name", text: Binding(get: { node.agents?[i].name ?? "" }, set: { node.agents?[i].name = $0 }))
.textFieldStyle(.plain)
.font(Fonts.font(13, weight: 700))
ModelPicker(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 }))
ModelPicker(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 }),
effort: Binding(get: { node.agents?[i].effort ?? "" }, set: { node.agents?[i].effort = $0.isEmpty ? nil : $0 }))
}
.padding(.top, 4)
PromptEditor(text: Binding(get: { node.agents?[i].prompt ?? "" }, set: { node.agents?[i].prompt = $0 }))
@@ -443,7 +447,7 @@ private struct StepRow: View {
private let groupKinds: [(StepNode.Kind, String)] = [(.order, "in order"), (.parallel, "in parallel"), (.random, "one at random")]
private func stage() -> StepNode {
newStage(root, store.defaultModel)
newStage(root, store.defaultModel, store.defaultEffort)
}
private func addAfter() {
@@ -528,10 +532,17 @@ private struct WeightField: View {
struct ModelPicker: View {
@Binding var model: String
@Binding var effort: String
var body: some View {
let list = model.isEmpty || models.contains(model) ? models : [model] + models
ChoiceMenu(value: $model, options: list.map { ($0, $0) }, placeholder: "model")
HStack(spacing: 6) {
ChoiceMenu(value: $model, options: list.map { ($0, $0) }, placeholder: "model")
ChoiceMenu(value: $effort, options: efforts(model).map { ($0, effortLabel($0)) })
}
.onChange(of: model) {
effort = effortFor(model, effort)
}
}
}