| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | ProviderItemId, | 20 | ProviderItemId, |
| 21 | ThreadId, | 21 | ThreadId, |
| 22 | TurnId, | 22 | TurnId, |
| | 23 | ProviderSendTurnInput, |
| 23 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 24 | import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; | 25 | import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; |
| 25 | | 26 | |
| ... | @@ -1327,38 +1328,40 @@ function mapToRuntimeEvents( | ... | @@ -1327,38 +1328,40 @@ function mapToRuntimeEvents( |
| 1327 | return []; | 1328 | return []; |
| 1328 | } | 1329 | } |
| 1329 | | 1330 | |
| 1330 | const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | 1331 | const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1331 | Effect.gen(function* () { | 1332 | options?: CodexAdapterLiveOptions, |
| 1332 | const fileSystem = yield* FileSystem.FileSystem; | 1333 | ) { |
| 1333 | const serverConfig = yield* Effect.service(ServerConfig); | 1334 | const fileSystem = yield* FileSystem.FileSystem; |
| 1334 | const nativeEventLogger = | 1335 | const serverConfig = yield* Effect.service(ServerConfig); |
| 1335 | options?.nativeEventLogger ?? | 1336 | const nativeEventLogger = |
| 1336 | (options?.nativeEventLogPath !== undefined | 1337 | options?.nativeEventLogger ?? |
| 1337 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { | 1338 | (options?.nativeEventLogPath !== undefined |
| 1338 | stream: "native", | 1339 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { |
| 1339 | }) | 1340 | stream: "native", |
| 1340 | : undefined); | 1341 | }) |
| 1341 | | 1342 | : undefined); |
| 1342 | const manager = yield* Effect.acquireRelease( | | |
| 1343 | Effect.gen(function* () { | | |
| 1344 | if (options?.manager) { | | |
| 1345 | return options.manager; | | |
| 1346 | } | | |
| 1347 | const services = yield* Effect.services<never>(); | | |
| 1348 | return options?.makeManager?.(services) ?? new CodexAppServerManager(services); | | |
| 1349 | }), | | |
| 1350 | (manager) => | | |
| 1351 | Effect.sync(() => { | | |
| 1352 | try { | | |
| 1353 | manager.stopAll(); | | |
| 1354 | } catch { | | |
| 1355 | // Finalizers should never fail and block shutdown. | | |
| 1356 | } | | |
| 1357 | }), | | |
| 1358 | ); | | |
| 1359 | const serverSettingsService = yield* ServerSettingsService; | | |
| 1360 | | 1343 | |
| 1361 | const startSession: CodexAdapterShape["startSession"] = Effect.fn(function* (input) { | 1344 | const acquireManager = Effect.fn("acquireManager")(function* () { |
| | 1345 | if (options?.manager) { |
| | 1346 | return options.manager; |
| | 1347 | } |
| | 1348 | const services = yield* Effect.services<never>(); |
| | 1349 | return options?.makeManager?.(services) ?? new CodexAppServerManager(services); |
| | 1350 | }); |
| | 1351 | |
| | 1352 | const manager = yield* Effect.acquireRelease(acquireManager(), (manager) => |
| | 1353 | Effect.sync(() => { |
| | 1354 | try { |
| | 1355 | manager.stopAll(); |
| | 1356 | } catch { |
| | 1357 | // Finalizers should never fail and block shutdown. |
| | 1358 | } |
| | 1359 | }), |
| | 1360 | ); |
| | 1361 | const serverSettingsService = yield* ServerSettingsService; |
| | 1362 | |
| | 1363 | const startSession: CodexAdapterShape["startSession"] = Effect.fn("startSession")( |
| | 1364 | function* (input) { |
| 1362 | if (input.provider !== undefined && input.provider !== PROVIDER) { | 1365 | if (input.provider !== undefined && input.provider !== PROVIDER) { |
| 1363 | return yield* new ProviderAdapterValidationError({ | 1366 | return yield* new ProviderAdapterValidationError({ |
| 1364 | provider: PROVIDER, | 1367 | provider: PROVIDER, |
| ... | @@ -1407,211 +1410,212 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -1407,211 +1410,212 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1407 | cause, | 1410 | cause, |
| 1408 | }), | 1411 | }), |
| 1409 | }); | 1412 | }); |
| | 1413 | }, |
| | 1414 | ); |
| | 1415 | |
| | 1416 | const resolveAttachment = Effect.fn("resolveAttachment")(function* ( |
| | 1417 | input: ProviderSendTurnInput, |
| | 1418 | attachment: NonNullable<ProviderSendTurnInput["attachments"]>[number], |
| | 1419 | ) { |
| | 1420 | const attachmentPath = resolveAttachmentPath({ |
| | 1421 | attachmentsDir: serverConfig.attachmentsDir, |
| | 1422 | attachment, |
| 1410 | }); | 1423 | }); |
| | 1424 | if (!attachmentPath) { |
| | 1425 | return yield* toRequestError( |
| | 1426 | input.threadId, |
| | 1427 | "turn/start", |
| | 1428 | new Error(`Invalid attachment id '${attachment.id}'.`), |
| | 1429 | ); |
| | 1430 | } |
| | 1431 | const bytes = yield* fileSystem.readFile(attachmentPath).pipe( |
| | 1432 | Effect.mapError( |
| | 1433 | (cause) => |
| | 1434 | new ProviderAdapterRequestError({ |
| | 1435 | provider: PROVIDER, |
| | 1436 | method: "turn/start", |
| | 1437 | detail: toMessage(cause, "Failed to read attachment file."), |
| | 1438 | cause, |
| | 1439 | }), |
| | 1440 | ), |
| | 1441 | ); |
| | 1442 | return { |
| | 1443 | type: "image" as const, |
| | 1444 | url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, |
| | 1445 | }; |
| | 1446 | }); |
| 1411 | | 1447 | |
| 1412 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => | 1448 | const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { |
| 1413 | Effect.gen(function* () { | 1449 | const codexAttachments = yield* Effect.forEach( |
| 1414 | const codexAttachments = yield* Effect.forEach( | 1450 | input.attachments ?? [], |
| 1415 | input.attachments ?? [], | 1451 | (attachment) => resolveAttachment(input, attachment), |
| 1416 | (attachment) => | 1452 | { concurrency: 1 }, |
| 1417 | Effect.gen(function* () { | 1453 | ); |
| 1418 | const attachmentPath = resolveAttachmentPath({ | | |
| 1419 | attachmentsDir: serverConfig.attachmentsDir, | | |
| 1420 | attachment, | | |
| 1421 | }); | | |
| 1422 | if (!attachmentPath) { | | |
| 1423 | return yield* toRequestError( | | |
| 1424 | input.threadId, | | |
| 1425 | "turn/start", | | |
| 1426 | new Error(`Invalid attachment id '${attachment.id}'.`), | | |
| 1427 | ); | | |
| 1428 | } | | |
| 1429 | const bytes = yield* fileSystem.readFile(attachmentPath).pipe( | | |
| 1430 | Effect.mapError( | | |
| 1431 | (cause) => | | |
| 1432 | new ProviderAdapterRequestError({ | | |
| 1433 | provider: PROVIDER, | | |
| 1434 | method: "turn/start", | | |
| 1435 | detail: toMessage(cause, "Failed to read attachment file."), | | |
| 1436 | cause, | | |
| 1437 | }), | | |
| 1438 | ), | | |
| 1439 | ); | | |
| 1440 | return { | | |
| 1441 | type: "image" as const, | | |
| 1442 | url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, | | |
| 1443 | }; | | |
| 1444 | }), | | |
| 1445 | { concurrency: 1 }, | | |
| 1446 | ); | | |
| 1447 | | 1454 | |
| 1448 | return yield* Effect.tryPromise({ | 1455 | return yield* Effect.tryPromise({ |
| 1449 | try: () => { | 1456 | try: () => { |
| 1450 | const managerInput = { | 1457 | const managerInput = { |
| 1451 | threadId: input.threadId, | 1458 | threadId: input.threadId, |
| 1452 | ...(input.input !== undefined ? { input: input.input } : {}), | 1459 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1453 | ...(input.modelSelection?.provider === "codex" | 1460 | ...(input.modelSelection?.provider === "codex" |
| 1454 | ? { model: input.modelSelection.model } | 1461 | ? { model: input.modelSelection.model } |
| 1455 | : {}), | 1462 | : {}), |
| 1456 | ...(input.modelSelection?.provider === "codex" && | 1463 | ...(input.modelSelection?.provider === "codex" && |
| 1457 | input.modelSelection.options?.reasoningEffort !== undefined | 1464 | input.modelSelection.options?.reasoningEffort !== undefined |
| 1458 | ? { effort: input.modelSelection.options.reasoningEffort } | 1465 | ? { effort: input.modelSelection.options.reasoningEffort } |
| 1459 | : {}), | 1466 | : {}), |
| 1460 | ...(input.modelSelection?.provider === "codex" && | 1467 | ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode |
| 1461 | input.modelSelection.options?.fastMode | 1468 | ? { serviceTier: "fast" } |
| 1462 | ? { serviceTier: "fast" } | 1469 | : {}), |
| 1463 | : {}), | 1470 | ...(input.interactionMode !== undefined |
| 1464 | ...(input.interactionMode !== undefined | 1471 | ? { interactionMode: input.interactionMode } |
| 1465 | ? { interactionMode: input.interactionMode } | 1472 | : {}), |
| 1466 | : {}), | 1473 | ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), |
| 1467 | ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), | 1474 | }; |
| 1468 | }; | 1475 | return manager.sendTurn(managerInput); |
| 1469 | return manager.sendTurn(managerInput); | 1476 | }, |
| 1470 | }, | 1477 | catch: (cause) => toRequestError(input.threadId, "turn/start", cause), |
| 1471 | catch: (cause) => toRequestError(input.threadId, "turn/start", cause), | 1478 | }).pipe( |
| 1472 | }).pipe( | 1479 | Effect.map((result) => ({ |
| 1473 | Effect.map((result) => ({ | 1480 | ...result, |
| 1474 | ...result, | 1481 | threadId: input.threadId, |
| 1475 | threadId: input.threadId, | 1482 | })), |
| 1476 | })), | 1483 | ); |
| 1477 | ); | 1484 | }); |
| 1478 | }); | | |
| 1479 | | 1485 | |
| 1480 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => | 1486 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => |
| 1481 | Effect.tryPromise({ | 1487 | Effect.tryPromise({ |
| 1482 | try: () => manager.interruptTurn(threadId, turnId), | 1488 | try: () => manager.interruptTurn(threadId, turnId), |
| 1483 | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), | 1489 | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), |
| 1484 | }); | 1490 | }); |
| 1485 | | 1491 | |
| 1486 | const readThread: CodexAdapterShape["readThread"] = (threadId) => | 1492 | const readThread: CodexAdapterShape["readThread"] = (threadId) => |
| 1487 | Effect.tryPromise({ | 1493 | Effect.tryPromise({ |
| 1488 | try: () => manager.readThread(threadId), | 1494 | try: () => manager.readThread(threadId), |
| 1489 | catch: (cause) => toRequestError(threadId, "thread/read", cause), | 1495 | catch: (cause) => toRequestError(threadId, "thread/read", cause), |
| 1490 | }).pipe( | 1496 | }).pipe( |
| 1491 | Effect.map((snapshot) => ({ | 1497 | Effect.map((snapshot) => ({ |
| 1492 | threadId, | 1498 | threadId, |
| 1493 | turns: snapshot.turns, | 1499 | turns: snapshot.turns, |
| 1494 | })), | 1500 | })), |
| | 1501 | ); |
| | 1502 | |
| | 1503 | const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => { |
| | 1504 | if (!Number.isInteger(numTurns) || numTurns < 1) { |
| | 1505 | return Effect.fail( |
| | 1506 | new ProviderAdapterValidationError({ |
| | 1507 | provider: PROVIDER, |
| | 1508 | operation: "rollbackThread", |
| | 1509 | issue: "numTurns must be an integer >= 1.", |
| | 1510 | }), |
| 1495 | ); | 1511 | ); |
| | 1512 | } |
| 1496 | | 1513 | |
| 1497 | const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => { | 1514 | return Effect.tryPromise({ |
| 1498 | if (!Number.isInteger(numTurns) || numTurns < 1) { | 1515 | try: () => manager.rollbackThread(threadId, numTurns), |
| 1499 | return Effect.fail( | 1516 | catch: (cause) => toRequestError(threadId, "thread/rollback", cause), |
| 1500 | new ProviderAdapterValidationError({ | 1517 | }).pipe( |
| 1501 | provider: PROVIDER, | 1518 | Effect.map((snapshot) => ({ |
| 1502 | operation: "rollbackThread", | 1519 | threadId, |
| 1503 | issue: "numTurns must be an integer >= 1.", | 1520 | turns: snapshot.turns, |
| 1504 | }), | 1521 | })), |
| 1505 | ); | 1522 | ); |
| 1506 | } | 1523 | }; |
| 1507 | | 1524 | |
| 1508 | return Effect.tryPromise({ | 1525 | const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => |
| 1509 | try: () => manager.rollbackThread(threadId, numTurns), | 1526 | Effect.tryPromise({ |
| 1510 | catch: (cause) => toRequestError(threadId, "thread/rollback", cause), | 1527 | try: () => manager.respondToRequest(threadId, requestId, decision), |
| 1511 | }).pipe( | 1528 | catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), |
| 1512 | Effect.map((snapshot) => ({ | 1529 | }); |
| 1513 | threadId, | | |
| 1514 | turns: snapshot.turns, | | |
| 1515 | })), | | |
| 1516 | ); | | |
| 1517 | }; | | |
| 1518 | | 1530 | |
| 1519 | const respondToRequest: CodexAdapterShape["respondToRequest"] = ( | 1531 | const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( |
| 1520 | threadId, | 1532 | threadId, |
| 1521 | requestId, | 1533 | requestId, |
| 1522 | decision, | 1534 | answers, |
| 1523 | ) => | 1535 | ) => |
| 1524 | Effect.tryPromise({ | 1536 | Effect.tryPromise({ |
| 1525 | try: () => manager.respondToRequest(threadId, requestId, decision), | 1537 | try: () => manager.respondToUserInput(threadId, requestId, answers), |
| 1526 | catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), | 1538 | catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), |
| 1527 | }); | 1539 | }); |
| 1528 | | 1540 | |
| 1529 | const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( | 1541 | const stopSession: CodexAdapterShape["stopSession"] = (threadId) => |
| 1530 | threadId, | 1542 | Effect.sync(() => { |
| 1531 | requestId, | 1543 | manager.stopSession(threadId); |
| 1532 | answers, | 1544 | }); |
| 1533 | ) => | | |
| 1534 | Effect.tryPromise({ | | |
| 1535 | try: () => manager.respondToUserInput(threadId, requestId, answers), | | |
| 1536 | catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), | | |
| 1537 | }); | | |
| 1538 | | 1545 | |
| 1539 | const stopSession: CodexAdapterShape["stopSession"] = (threadId) => | 1546 | const listSessions: CodexAdapterShape["listSessions"] = () => |
| 1540 | Effect.sync(() => { | 1547 | Effect.sync(() => manager.listSessions()); |
| 1541 | manager.stopSession(threadId); | | |
| 1542 | }); | | |
| 1543 | | 1548 | |
| 1544 | const listSessions: CodexAdapterShape["listSessions"] = () => | 1549 | const hasSession: CodexAdapterShape["hasSession"] = (threadId) => |
| 1545 | Effect.sync(() => manager.listSessions()); | 1550 | Effect.sync(() => manager.hasSession(threadId)); |
| 1546 | | 1551 | |
| 1547 | const hasSession: CodexAdapterShape["hasSession"] = (threadId) => | 1552 | const stopAll: CodexAdapterShape["stopAll"] = () => |
| 1548 | Effect.sync(() => manager.hasSession(threadId)); | 1553 | Effect.sync(() => { |
| | 1554 | manager.stopAll(); |
| | 1555 | }); |
| 1549 | | 1556 | |
| 1550 | const stopAll: CodexAdapterShape["stopAll"] = () => | 1557 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 1551 | Effect.sync(() => { | | |
| 1552 | manager.stopAll(); | | |
| 1553 | }); | | |
| 1554 | | 1558 | |
| 1555 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 1559 | const writeNativeEvent = Effect.fn("writeNativeEvent")(function* (event: ProviderEvent) { |
| 1556 | | 1560 | if (!nativeEventLogger) { |
| 1557 | yield* Effect.acquireRelease( | 1561 | return; |
| 1558 | Effect.gen(function* () { | 1562 | } |
| 1559 | const writeNativeEvent = (event: ProviderEvent) => | 1563 | yield* nativeEventLogger.write(event, event.threadId); |
| 1560 | Effect.gen(function* () { | 1564 | }); |
| 1561 | if (!nativeEventLogger) { | | |
| 1562 | return; | | |
| 1563 | } | | |
| 1564 | yield* nativeEventLogger.write(event, event.threadId); | | |
| 1565 | }); | | |
| 1566 | | | |
| 1567 | const services = yield* Effect.services<never>(); | | |
| 1568 | const listener = (event: ProviderEvent) => | | |
| 1569 | Effect.gen(function* () { | | |
| 1570 | yield* writeNativeEvent(event); | | |
| 1571 | const runtimeEvents = mapToRuntimeEvents(event, event.threadId); | | |
| 1572 | if (runtimeEvents.length === 0) { | | |
| 1573 | yield* Effect.logDebug("ignoring unhandled Codex provider event", { | | |
| 1574 | method: event.method, | | |
| 1575 | threadId: event.threadId, | | |
| 1576 | turnId: event.turnId, | | |
| 1577 | itemId: event.itemId, | | |
| 1578 | }); | | |
| 1579 | return; | | |
| 1580 | } | | |
| 1581 | yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); | | |
| 1582 | }).pipe(Effect.runPromiseWith(services)); | | |
| 1583 | manager.on("event", listener); | | |
| 1584 | return listener; | | |
| 1585 | }), | | |
| 1586 | (listener) => | | |
| 1587 | Effect.gen(function* () { | | |
| 1588 | yield* Effect.sync(() => { | | |
| 1589 | manager.off("event", listener); | | |
| 1590 | }); | | |
| 1591 | yield* Queue.shutdown(runtimeEventQueue); | | |
| 1592 | }), | | |
| 1593 | ); | | |
| 1594 | | 1565 | |
| 1595 | return { | 1566 | const registerListener = Effect.fn("registerListener")(function* () { |
| 1596 | provider: PROVIDER, | 1567 | const services = yield* Effect.services<never>(); |
| 1597 | capabilities: { | 1568 | const listenerEffect = Effect.fn("listener")(function* (event: ProviderEvent) { |
| 1598 | sessionModelSwitch: "in-session", | 1569 | yield* writeNativeEvent(event); |
| 1599 | }, | 1570 | const runtimeEvents = mapToRuntimeEvents(event, event.threadId); |
| 1600 | startSession, | 1571 | if (runtimeEvents.length === 0) { |
| 1601 | sendTurn, | 1572 | yield* Effect.logDebug("ignoring unhandled Codex provider event", { |
| 1602 | interruptTurn, | 1573 | method: event.method, |
| 1603 | readThread, | 1574 | threadId: event.threadId, |
| 1604 | rollbackThread, | 1575 | turnId: event.turnId, |
| 1605 | respondToRequest, | 1576 | itemId: event.itemId, |
| 1606 | respondToUserInput, | 1577 | }); |
| 1607 | stopSession, | 1578 | return; |
| 1608 | listSessions, | 1579 | } |
| 1609 | hasSession, | 1580 | yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); |
| 1610 | stopAll, | 1581 | }); |
| 1611 | streamEvents: Stream.fromQueue(runtimeEventQueue), | 1582 | const listener = (event: ProviderEvent) => |
| 1612 | } satisfies CodexAdapterShape; | 1583 | listenerEffect(event).pipe(Effect.runPromiseWith(services)); |
| | 1584 | manager.on("event", listener); |
| | 1585 | return listener; |
| | 1586 | }); |
| | 1587 | |
| | 1588 | const unregisterListener = Effect.fn("unregisterListener")(function* ( |
| | 1589 | listener: (event: ProviderEvent) => Promise<void>, |
| | 1590 | ) { |
| | 1591 | yield* Effect.sync(() => { |
| | 1592 | manager.off("event", listener); |
| | 1593 | }); |
| | 1594 | yield* Queue.shutdown(runtimeEventQueue); |
| 1613 | }); | 1595 | }); |
| 1614 | | 1596 | |
| | 1597 | yield* Effect.acquireRelease(registerListener(), unregisterListener); |
| | 1598 | |
| | 1599 | return { |
| | 1600 | provider: PROVIDER, |
| | 1601 | capabilities: { |
| | 1602 | sessionModelSwitch: "in-session", |
| | 1603 | }, |
| | 1604 | startSession, |
| | 1605 | sendTurn, |
| | 1606 | interruptTurn, |
| | 1607 | readThread, |
| | 1608 | rollbackThread, |
| | 1609 | respondToRequest, |
| | 1610 | respondToUserInput, |
| | 1611 | stopSession, |
| | 1612 | listSessions, |
| | 1613 | hasSession, |
| | 1614 | stopAll, |
| | 1615 | streamEvents: Stream.fromQueue(runtimeEventQueue), |
| | 1616 | } satisfies CodexAdapterShape; |
| | 1617 | }); |
| | 1618 | |
| 1615 | export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); | 1619 | export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); |
| 1616 | | 1620 | |
| 1617 | export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { | 1621 | export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { |